diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index f4752a3ab..ee41489af 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -37,20 +37,28 @@ data: platform_toolsets: cli: + - browser - clarify + - delegation - file + - memory - session_search - skills - terminal - todo + - vision - web api_server: + - browser - clarify + - delegation - file + - memory - session_search - skills - terminal - todo + - vision - web gateway: @@ -145,6 +153,11 @@ data: risk analysis, and independent review. Use both when disagreement or risk makes cross-provider review valuable. Never exceed xhigh effort. + Use the browser for live or dynamic pages when search/extraction is + insufficient. Use terminal and file tools for direct engineering work; use + Herdr workers when an objective benefits from persistent Codex or Claude + Code execution, parallel review, or cross-provider fallback. + Local Jetson inference is the first provider-independent fallback. Use it for bounded classification, summaries, and continuity when hosted capacity is constrained. Do not silently treat a local fallback as equivalent to a diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index aee3b2c03..f4122d488 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -24,7 +24,7 @@ spec: ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260809-agent-tool-budget" + ai.bstein.dev/config-rev: "20260809-browser-delegation-tools" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens diff --git a/services/hermes/chat-configmap.yaml b/services/hermes/chat-configmap.yaml index f8a310ec9..57fe1da21 100644 --- a/services/hermes/chat-configmap.yaml +++ b/services/hermes/chat-configmap.yaml @@ -39,8 +39,8 @@ data: enabled: true ttl_hours: 1 platform_toolsets: - cli: [clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, web] - api_server: [clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, web] + cli: [browser, clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, vision, web] + api_server: [browser, clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, vision, web] dashboard: public_url: https://chat.hermes.bstein.dev display: @@ -63,7 +63,9 @@ data: You are a high-quality private AI chat assistant. Help the current person with questions, writing, research, planning, and learning. Be direct, thoughtful, and careful. Use public web research when freshness matters, - extract the most relevant primary pages, and cite the sources used. + extract the most relevant primary pages, and cite the sources used. If a + search result or static extractor cannot retrieve a dynamic page, continue + with the isolated browser instead of giving up or asking the user to check. Complete complex work instead of stopping after a preflight. For involved research or analysis, make a short internal plan, delegate independent @@ -71,6 +73,9 @@ data: statistics/probability/simulation, check the result, and synthesize one coherent answer. State assumptions and uncertainty where exact inputs are unavailable. Never tell the user to enable a tool that is already present. + Save requested deliverables under `/opt/data/workspace`, verify them by + reading them back, and give the relative path. The user can preview and + download those outputs from the Files shortcut in the WebUI. This is a personal sandbox. You may read and write files only in this user's private workspace and may use this user's private memory, skills, @@ -85,7 +90,7 @@ data: This runtime belongs to one authenticated Keycloak identity and one private persistent volume. Provide conversational help with the private workspace, memory, skills, profiles, task list, session search, public web tools, and - the separate per-tenant Python sandbox. Use delegation selectively for + the separate per-tenant browser and Python sandbox. Use delegation selectively for independent research or verification, then present a single final answer. Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other users, the agent coordinator, or automated triage. diff --git a/services/hermes/chat-router.yaml b/services/hermes/chat-router.yaml index de63b23f5..909f2f294 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-private-workspace-files" + ai.bstein.dev/config-rev: "20260809-private-file-browser" 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:165dd52fd1ff6da9f857517e9394d0c61a888311a88ba4be0aaea89315073d90 + image: registry.bstein.dev/bstein/hermes-chat-router@sha256:882f9c3af7268618a19424765536f4cf6c37e8d380e515780baf4104e5b2931e imagePullPolicy: IfNotPresent ports: - {name: http, containerPort: 8080, protocol: TCP} diff --git a/services/hermes/chat-statefulset.yaml b/services/hermes/chat-statefulset.yaml index 778ebf6eb..03d039756 100644 --- a/services/hermes/chat-statefulset.yaml +++ b/services/hermes/chat-statefulset.yaml @@ -28,7 +28,7 @@ spec: ai.bstein.dev/role: isolated-user-chat 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: "20260809-tool-loop-budgets" + ai.bstein.dev/config-rev: "20260809-browser-files-auth-refresh" 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 @@ -180,7 +180,7 @@ spec: - {name: api, containerPort: 8642, protocol: TCP} env: - {name: HERMES_HOME, value: /opt/data} - - {name: HERMES_AUTH_FILE, value: /opt/data/auth.json} + - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json} - {name: HOME, value: /opt/data/home} - {name: TERMINAL_CWD, value: /opt/data/workspace} - {name: HERMES_WRITE_SAFE_ROOT, value: /opt/data/workspace} @@ -192,7 +192,7 @@ spec: - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev} volumeMounts: - {name: home, mountPath: /opt/data} - - {name: provider-auth, mountPath: /opt/data/auth.json, subPath: auth.json, readOnly: true} + - {name: provider-auth, mountPath: /shared-auth, readOnly: true} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py} readinessProbe: tcpSocket: {port: api} @@ -228,7 +228,7 @@ spec: - {name: webui, containerPort: 8787, protocol: TCP} env: - {name: HERMES_HOME, value: /opt/data} - - {name: HERMES_AUTH_FILE, value: /opt/data/auth.json} + - {name: HERMES_AUTH_FILE, value: /shared-auth/auth.json} - {name: HOME, value: /opt/data/home} - {name: HERMES_WEBUI_AGENT_DIR, value: /opt/hermes} - {name: HERMES_WEBUI_HOST, value: 0.0.0.0} @@ -245,7 +245,7 @@ spec: - {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"} volumeMounts: - {name: home, mountPath: /opt/data} - - {name: provider-auth, mountPath: /opt/data/auth.json, subPath: auth.json, readOnly: true} + - {name: provider-auth, mountPath: /shared-auth, readOnly: true} - {name: tmp, mountPath: /tmp} readinessProbe: httpGet: {path: /health, port: webui} diff --git a/services/hermes/oauth2-proxy.yaml b/services/hermes/oauth2-proxy.yaml index 252fae0ef..668a4a196 100644 --- a/services/hermes/oauth2-proxy.yaml +++ b/services/hermes/oauth2-proxy.yaml @@ -9,6 +9,43 @@ data: brad@bstein.dev --- apiVersion: v1 +kind: ConfigMap +metadata: + name: hermes-chat-oauth-templates + namespace: hermes +data: + error.html: | + {{define "error.html"}} + + + + + + {{.StatusCode}} {{.Title}} + {{if eq .StatusCode 500}}{{end}} + + + +
+ {{if eq .StatusCode 500}} +

Signing you back in…

+

The previous one-time login callback was already used. Hermes is starting a fresh sign-in automatically.

+ Continue now + {{else}} +

{{.StatusCode}} {{.Title}}

+ {{if .Message}}

{{.Message}}

{{end}} + Sign in again + {{end}} +
+ + + {{end}} +--- +apiVersion: v1 kind: Service metadata: name: oauth2-proxy-hermes-agent @@ -239,6 +276,7 @@ spec: labels: app: oauth2-proxy-hermes-chat annotations: + ai.bstein.dev/config-rev: "20260809-consumed-callback-recovery" vault.hashicorp.com/agent-inject: "true" vault.hashicorp.com/agent-pre-populate-only: "true" vault.hashicorp.com/role: hermes-chat @@ -275,6 +313,7 @@ spec: - --cookie-samesite=lax - --cookie-refresh=1h - --cookie-expire=8h + - --custom-templates-dir=/etc/oauth2-proxy/templates - '--skip-auth-route=GET=^/sw[.]js([?].*)?$' - --upstream=http://hermes-chat-router.hermes.svc.cluster.local:8080 - --http-address=0.0.0.0:4180 @@ -302,7 +341,11 @@ spec: requests: {cpu: 25m, memory: 64Mi} limits: {cpu: 250m, memory: 256Mi} volumeMounts: + - {name: templates, mountPath: /etc/oauth2-proxy/templates, readOnly: true} - {name: tmp, mountPath: /tmp} volumes: + - name: templates + configMap: + name: hermes-chat-oauth-templates - name: tmp emptyDir: {sizeLimit: 64Mi} diff --git a/services/hermes/router/main.go b/services/hermes/router/main.go index b90d10270..5ae794c63 100644 --- a/services/hermes/router/main.go +++ b/services/hermes/router/main.go @@ -252,7 +252,8 @@ func privateWorkspaceReadAllowed(method, path string) bool { return false } switch path { - case "/api/workspaces", "/api/workspaces/suggest", "/api/file", "/api/file/raw", "/api/folder/download": + case "/api/workspaces", "/api/workspaces/suggest", "/api/file", "/api/file/raw", "/api/folder/download", + "/api/logs", "/api/rollback/list", "/api/rollback/diff": return true default: return false diff --git a/services/hermes/router/main_test.go b/services/hermes/router/main_test.go index 31f26ef47..50f720dc8 100644 --- a/services/hermes/router/main_test.go +++ b/services/hermes/router/main_test.go @@ -163,6 +163,9 @@ func TestRouterAllowsPrivateWorkspaceReadsButBlocksRegistration(t *testing.T) { "/api/file", "/api/file/raw", "/api/folder/download", + "/api/logs", + "/api/rollback/list", + "/api/rollback/diff", } { request := httptest.NewRequest(http.MethodGet, path, nil) request.Header.Set("X-Forwarded-User", "subject") @@ -182,6 +185,38 @@ func TestRouterAllowsPrivateWorkspaceReadsButBlocksRegistration(t *testing.T) { } } +func TestPrivateFileBrowserIsTenantAuthenticatedAndHidesKanban(t *testing.T) { + router, err := newTenantRouter(filepath.Join(t.TempDir(), "state.json"), 1, func(slot int) string { return "" }) + if err != nil { + t.Fatal(err) + } + unauthenticated := httptest.NewRequest(http.MethodGet, "/private-files", nil) + unauthenticatedResponse := httptest.NewRecorder() + router.ServeHTTP(unauthenticatedResponse, unauthenticated) + if unauthenticatedResponse.Code != http.StatusUnauthorized { + t.Fatalf("unauthenticated browser got %d", unauthenticatedResponse.Code) + } + + request := httptest.NewRequest(http.MethodGet, "/private-files?session_id=session-1", nil) + request.Header.Set("X-Forwarded-User", "subject") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("got %d", response.Code) + } + if !strings.Contains(response.Body.String(), "Private files") || !strings.Contains(response.Body.String(), "data-files-page") { + t.Fatal("private browser page was not rendered") + } + + assetRequest := httptest.NewRequest(http.MethodGet, "/hermes-chat-bridge.js", nil) + assetRequest.Header.Set("X-Forwarded-User", "subject") + assetResponse := httptest.NewRecorder() + router.ServeHTTP(assetResponse, assetRequest) + if !strings.Contains(assetResponse.Body.String(), "endpoint('list'") || !strings.Contains(assetResponse.Body.String(), `'[data-panel="kanban"]'`) { + t.Fatal("file browsing or chat-only navigation policy is missing") + } +} + 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/services/hermes/router/web.go b/services/hermes/router/web.go index 9dfc4cc44..58d168ac2 100644 --- a/services/hermes/router/web.go +++ b/services/hermes/router/web.go @@ -17,7 +17,7 @@ const telegramPage = ` Hermes on Telegram - +
@@ -32,32 +32,183 @@ const telegramPage = `

Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.

- + + +` + +const privateFilesPage = ` + + + + + Hermes Private Files + + + +
+
+
+ ← Back to Hermes +

Private files

+

Files created by Hermes in your isolated 10 GiB Home workspace.

+
+ +
+ +

Loading your workspace…

+
+
+
+ Files and folders + Download folder +
+
    +
    +
    +
    + Select a file + +
    +
    Choose a file to preview it here.
    +
    +
    +
    + ` const bridgeCSS = ` -#hermes-telegram-shortcut{position:fixed;right:18px;bottom:88px;z-index:9999;padding:10px 14px;border-radius:999px;background:#229ed9;color:#fff;text-decoration:none;font:600 14px system-ui,sans-serif;box-shadow:0 5px 20px #0005} +#hermes-chat-shortcuts{position:fixed;right:18px;top:82px;z-index:9999;display:flex;gap:8px;align-items:center;font:600 13px system-ui,sans-serif} +#hermes-chat-shortcuts a{padding:8px 12px;border-radius:999px;color:#fff;text-decoration:none;box-shadow:0 5px 20px #0005}#hermes-files-shortcut{background:#475569}#hermes-telegram-shortcut{background:#229ed9} .hermes-link-page{margin:0;min-height:100vh;display:grid;place-items:center;background:#0f172a;color:#e2e8f0;font:16px/1.5 system-ui,sans-serif} .hermes-link-card{width:min(620px,calc(100% - 40px));box-sizing:border-box;padding:32px;border:1px solid #334155;border-radius:18px;background:#111827;box-shadow:0 20px 60px #0006} .hermes-link-card h1{margin:.6rem 0}.hermes-back{color:#7dd3fc}.hermes-link-actions{display:flex;gap:12px;flex-wrap:wrap;margin:24px 0} .hermes-link-card button{border:0;border-radius:10px;padding:11px 16px;background:#229ed9;color:#fff;font-weight:700;cursor:pointer}.hermes-link-card button.secondary{background:#334155}.hermes-link-card button:disabled{cursor:not-allowed;opacity:.45} #telegram-result{padding:16px;border-radius:10px;background:#1e293b;overflow-wrap:anywhere}#telegram-result a{color:#7dd3fc}.hermes-fine-print{color:#94a3b8;font-size:13px} +.hermes-files-page{margin:0;min-height:100vh;background:#0b1020;color:#e5e7eb;font:15px/1.5 system-ui,sans-serif}.hermes-files-shell{width:min(1500px,calc(100% - 36px));margin:auto;padding:28px 0}.hermes-files-header{display:flex;justify-content:space-between;gap:28px;align-items:end;border-bottom:1px solid #293249;padding-bottom:18px}.hermes-files-header h1{margin:.4rem 0 0}.hermes-files-header p{margin:.25rem 0;color:#9ca3af}.hermes-files-header label{display:grid;gap:6px;color:#9ca3af}.hermes-files-header select{min-width:260px;background:#151b2e;color:#e5e7eb;border:1px solid #39445f;border-radius:8px;padding:9px}.hermes-breadcrumbs{display:flex;gap:6px;flex-wrap:wrap;margin:18px 0}.hermes-breadcrumbs button{border:0;background:transparent;color:#7dd3fc;cursor:pointer;padding:4px}.hermes-files-grid{display:grid;grid-template-columns:minmax(280px,38%) 1fr;gap:18px}.hermes-files-grid>section{border:1px solid #293249;border-radius:12px;background:#11172a;min-height:65vh;overflow:hidden}.hermes-files-toolbar{min-height:42px;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 14px;border-bottom:1px solid #293249}.hermes-button{padding:6px 10px;border-radius:7px;background:#334155;color:#e5e7eb;text-decoration:none}.hermes-file-list{list-style:none;margin:0;padding:8px}.hermes-file-list button{width:100%;display:grid;grid-template-columns:1fr auto;gap:14px;text-align:left;border:0;border-radius:7px;padding:9px 10px;background:transparent;color:#e5e7eb;cursor:pointer}.hermes-file-list button:hover,.hermes-file-list button:focus{background:#202941}.hermes-file-meta{color:#8d98ad;font-size:12px}.hermes-file-viewer pre{box-sizing:border-box;margin:0;padding:18px;max-height:calc(65vh - 64px);overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#d9e2f1;font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace}#files-status{color:#9ca3af}@media(max-width:800px){#hermes-chat-shortcuts{top:auto;bottom:112px}.hermes-files-header{display:block}.hermes-files-header label{margin-top:14px}.hermes-files-header select{width:100%;min-width:0}.hermes-files-grid{grid-template-columns:1fr}.hermes-files-grid>section{min-height:38vh}} ` const bridgeJS = `(() => { const page = document.querySelector('[data-telegram-page]'); - if (!page) { - if (!document.getElementById('hermes-telegram-shortcut')) { - const link = document.createElement('a'); - link.id = 'hermes-telegram-shortcut'; - link.href = '/telegram'; - link.textContent = 'Telegram'; - link.setAttribute('aria-label', 'Connect Hermes to Telegram'); - document.body.appendChild(link); + const filesPage = document.querySelector('[data-files-page]'); + const hideChatAdministration = () => { + document.querySelectorAll('[data-panel="kanban"]').forEach((node) => { node.hidden = true; }); + }; + hideChatAdministration(); + if (!page && !filesPage) { + if (!document.getElementById('hermes-chat-shortcuts')) { + const shortcuts = document.createElement('nav'); + shortcuts.id = 'hermes-chat-shortcuts'; + const match = location.pathname.match(/^\/session\/([^/]+)/); + const files = document.createElement('a'); + files.id = 'hermes-files-shortcut'; + files.href = '/private-files' + (match ? '?session_id=' + encodeURIComponent(match[1]) : ''); + files.textContent = 'Files'; + files.setAttribute('aria-label', 'Browse private Hermes files'); + const telegram = document.createElement('a'); + telegram.id = 'hermes-telegram-shortcut'; + telegram.href = '/telegram'; + telegram.textContent = 'Telegram'; + telegram.setAttribute('aria-label', 'Connect Hermes to Telegram'); + shortcuts.append(files, telegram); + document.body.appendChild(shortcuts); } return; } + if (filesPage) { + const params = new URLSearchParams(location.search); + const sessionPicker = document.getElementById('files-session'); + const list = document.getElementById('files-list'); + const status = document.getElementById('files-status'); + const breadcrumbs = document.getElementById('files-breadcrumbs'); + const title = document.getElementById('file-title'); + const content = document.getElementById('file-content'); + const fileDownload = document.getElementById('file-download'); + const folderDownload = document.getElementById('files-download-folder'); + const back = document.getElementById('files-back'); + let sessionId = params.get('session_id') || ''; + let currentPath = '.'; + const api = async (path) => { + const response = await fetch(path, {cache:'no-store'}); + let payload; + try { payload = await response.json(); } catch (_) { payload = {}; } + if (!response.ok) throw new Error(payload.error || payload.detail || ('Request failed (' + response.status + ')')); + return payload; + }; + const endpoint = (kind, path) => '/api/' + kind + '?session_id=' + encodeURIComponent(sessionId) + '&path=' + encodeURIComponent(path || '.'); + const renderBreadcrumbs = () => { + breadcrumbs.replaceChildren(); + const parts = currentPath === '.' ? [] : currentPath.split('/').filter(Boolean); + const roots = [{label:'Home', path:'.'}]; + let built = ''; + parts.forEach((part) => { built = built ? built + '/' + part : part; roots.push({label:part, path:built}); }); + roots.forEach((item, index) => { + if (index) breadcrumbs.append(document.createTextNode(' / ')); + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = item.label; + button.addEventListener('click', () => loadDirectory(item.path)); + breadcrumbs.appendChild(button); + }); + }; + const openFile = async (entry) => { + status.textContent = 'Opening ' + entry.name + '…'; + try { + const payload = await api(endpoint('file', entry.path)); + title.textContent = entry.path; + content.textContent = payload.content || '[Empty file]'; + fileDownload.href = endpoint('file/raw', entry.path); + fileDownload.setAttribute('download', entry.name); + fileDownload.hidden = false; + status.textContent = entry.size == null ? 'File loaded.' : 'File loaded (' + entry.size.toLocaleString() + ' bytes).'; + } catch (error) { status.textContent = error.message; } + }; + const loadDirectory = async (path) => { + currentPath = path || '.'; + status.textContent = 'Loading ' + (currentPath === '.' ? 'Home' : currentPath) + '…'; + title.textContent = 'Select a file'; + content.textContent = 'Choose a file to preview it here.'; + fileDownload.hidden = true; + renderBreadcrumbs(); + folderDownload.href = endpoint('folder/download', currentPath); + try { + const payload = await api(endpoint('list', currentPath)); + const entries = Array.isArray(payload.entries) ? payload.entries.slice() : []; + entries.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1)); + list.replaceChildren(); + if (!entries.length) { + const empty = document.createElement('li'); empty.textContent = 'This folder is empty.'; list.appendChild(empty); + } + entries.forEach((entry) => { + const item = document.createElement('li'); + const button = document.createElement('button'); button.type = 'button'; + const name = document.createElement('span'); name.textContent = (entry.type === 'dir' ? '📁 ' : '📄 ') + entry.name; + const meta = document.createElement('span'); meta.className = 'hermes-file-meta'; meta.textContent = entry.type === 'dir' ? 'folder' : ((entry.size || 0).toLocaleString() + ' B'); + button.append(name, meta); + button.addEventListener('click', () => entry.type === 'dir' ? loadDirectory(entry.path) : openFile(entry)); + item.appendChild(button); list.appendChild(item); + }); + status.textContent = entries.length + (entries.length === 1 ? ' item' : ' items') + ' in ' + (currentPath === '.' ? 'Home' : currentPath) + '.'; + } catch (error) { list.replaceChildren(); status.textContent = error.message; } + }; + const bootFiles = async () => { + try { + const payload = await api('/api/sessions?sidebar_source=webui&exclude_hidden=1'); + const sessions = (payload.sessions || []).filter((item) => item && item.session_id && !item.read_only); + if (!sessions.length) throw new Error('Start a chat first so Hermes can attach the private Home workspace.'); + if (!sessions.some((item) => item.session_id === sessionId)) sessionId = sessions[0].session_id; + sessionPicker.replaceChildren(); + sessions.forEach((item) => { + const option = document.createElement('option'); option.value = item.session_id; option.textContent = item.title || item.session_id; option.selected = item.session_id === sessionId; sessionPicker.appendChild(option); + }); + sessionPicker.addEventListener('change', () => { sessionId = sessionPicker.value; back.href = '/session/' + encodeURIComponent(sessionId); loadDirectory('.'); }); + back.href = '/session/' + encodeURIComponent(sessionId); + await loadDirectory('.'); + } catch (error) { status.textContent = error.message; } + }; + bootFiles(); + return; + } const status = document.getElementById('telegram-status'); const result = document.getElementById('telegram-result'); const linkButton = document.getElementById('telegram-link'); @@ -150,6 +301,16 @@ func (router *tenantRouter) serveTelegramWeb(writer http.ResponseWriter, request writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'") _, _ = io.WriteString(writer, telegramPage) return true + case "/private-files": + if request.Method != http.MethodGet { + http.Error(writer, "method not allowed", http.StatusMethodNotAllowed) + return true + } + writer.Header().Set("Content-Type", "text/html; charset=utf-8") + writer.Header().Set("Cache-Control", "no-store") + writer.Header().Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'") + _, _ = io.WriteString(writer, privateFilesPage) + return true case "/api/telegram/status": if request.Method != http.MethodGet { writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) @@ -222,8 +383,8 @@ func injectChatBridge(response *http.Response) error { _ = response.Body.Close() content := string(body) if !strings.Contains(content, "hermes-chat-bridge.js") { - content = strings.Replace(content, "", ``, 1) - content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) + content = strings.Replace(content, "", ``, 1) } response.Body = io.NopCloser(strings.NewReader(content)) response.ContentLength = int64(len(content)) diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 5c45ca032..d2c783f90 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -33,7 +33,9 @@ def test_chat_config_enables_real_research_compute_and_delegation(): for platform in ("cli", "api_server"): toolsets = config["platform_toolsets"][platform] assert "delegation" in toolsets + assert "browser" in toolsets assert "python_sandbox" in toolsets + assert "vision" in toolsets assert "web" in toolsets assert "terminal" not in toolsets assert "code_execution" not in toolsets @@ -100,6 +102,31 @@ def test_chat_oauth_allows_stale_service_worker_retirement(): args = deployment["spec"]["template"]["spec"]["containers"][0]["args"] assert "--skip-auth-route=GET=^/sw[.]js([?].*)?$" in args + assert "--custom-templates-dir=/etc/oauth2-proxy/templates" in args + + template = next( + document + for document in documents + if document["kind"] == "ConfigMap" + and document["metadata"]["name"] == "hermes-chat-oauth-templates" + )["data"]["error.html"] + assert 'http-equiv="refresh"' in template + assert "/sign_in?rd=/" in template + + +def test_chat_auth_file_mount_survives_atomic_provider_refresh(): + statefulset = _documents(HERMES / "chat-statefulset.yaml")[0] + containers = statefulset["spec"]["template"]["spec"]["containers"] + + for name in ("hermes", "webui"): + container = next(item for item in containers if item["name"] == name) + env = {item["name"]: item["value"] for item in container["env"]} + assert env["HERMES_AUTH_FILE"] == "/shared-auth/auth.json" + mount = next( + item for item in container["volumeMounts"] if item["name"] == "provider-auth" + ) + assert mount["mountPath"] == "/shared-auth" + assert "subPath" not in mount def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch): diff --git a/testing/tests/test_hermes_herdr.py b/testing/tests/test_hermes_herdr.py index 1373e57f2..ca1c45b77 100644 --- a/testing/tests/test_hermes_herdr.py +++ b/testing/tests/test_hermes_herdr.py @@ -239,3 +239,10 @@ def test_agent_coordinator_has_a_long_running_tool_budget(): assert config["agent"]["max_turns"] == 180 assert config["tool_loop_guardrails"]["hard_stop_enabled"] is True + for platform in ("cli", "api_server"): + tools = config["platform_toolsets"][platform] + assert "browser" in tools + assert "delegation" in tools + assert "memory" in tools + assert "terminal" in tools + assert "vision" in tools