fix(hermes): enable browser tools and private files
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 172

This commit is contained in:
jenkins 2026-08-09 12:55:20 -03:00
parent 6576a64143
commit 36399fce6e
11 changed files with 318 additions and 26 deletions

View File

@ -37,20 +37,28 @@ data:
platform_toolsets: platform_toolsets:
cli: cli:
- browser
- clarify - clarify
- delegation
- file - file
- memory
- session_search - session_search
- skills - skills
- terminal - terminal
- todo - todo
- vision
- web - web
api_server: api_server:
- browser
- clarify - clarify
- delegation
- file - file
- memory
- session_search - session_search
- skills - skills
- terminal - terminal
- todo - todo
- vision
- web - web
gateway: gateway:
@ -145,6 +153,11 @@ data:
risk analysis, and independent review. Use both when disagreement or risk risk analysis, and independent review. Use both when disagreement or risk
makes cross-provider review valuable. Never exceed xhigh effort. 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 Local Jetson inference is the first provider-independent fallback. Use it
for bounded classification, summaries, and continuity when hosted capacity for bounded classification, summaries, and continuity when hosted capacity
is constrained. Do not silently treat a local fallback as equivalent to a is constrained. Do not silently treat a local fallback as equivalent to a

View File

@ -24,7 +24,7 @@ spec:
ai.bstein.dev/execution: Herdr-supervised Codex and Claude Code 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/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/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/agent-inject: "true"
vault.hashicorp.com/role: hermes-agent vault.hashicorp.com/role: hermes-agent
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens

View File

@ -39,8 +39,8 @@ data:
enabled: true enabled: true
ttl_hours: 1 ttl_hours: 1
platform_toolsets: platform_toolsets:
cli: [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: [clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, web] api_server: [browser, clarify, delegation, file, memory, python_sandbox, session_search, skills, todo, vision, web]
dashboard: dashboard:
public_url: https://chat.hermes.bstein.dev public_url: https://chat.hermes.bstein.dev
display: display:
@ -63,7 +63,9 @@ data:
You are a high-quality private AI chat assistant. Help the current person You are a high-quality private AI chat assistant. Help the current person
with questions, writing, research, planning, and learning. Be direct, with questions, writing, research, planning, and learning. Be direct,
thoughtful, and careful. Use public web research when freshness matters, 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 Complete complex work instead of stopping after a preflight. For involved
research or analysis, make a short internal plan, delegate independent research or analysis, make a short internal plan, delegate independent
@ -71,6 +73,9 @@ data:
statistics/probability/simulation, check the result, and synthesize one statistics/probability/simulation, check the result, and synthesize one
coherent answer. State assumptions and uncertainty where exact inputs are coherent answer. State assumptions and uncertainty where exact inputs are
unavailable. Never tell the user to enable a tool that is already present. 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 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, 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 This runtime belongs to one authenticated Keycloak identity and one private
persistent volume. Provide conversational help with the private workspace, persistent volume. Provide conversational help with the private workspace,
memory, skills, profiles, task list, session search, public web tools, and 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. independent research or verification, then present a single final answer.
Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other Do not claim access to Kubernetes, Vault, Gitea, Brad's projects, other
users, the agent coordinator, or automated triage. users, the agent coordinator, or automated triage.

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-private-workspace-files" ai.bstein.dev/config-rev: "20260809-private-file-browser"
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:165dd52fd1ff6da9f857517e9394d0c61a888311a88ba4be0aaea89315073d90 image: registry.bstein.dev/bstein/hermes-chat-router@sha256:882f9c3af7268618a19424765536f4cf6c37e8d380e515780baf4104e5b2931e
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
ports: ports:
- {name: http, containerPort: 8080, protocol: TCP} - {name: http, containerPort: 8080, protocol: TCP}

View File

@ -28,7 +28,7 @@ spec:
ai.bstein.dev/role: isolated-user-chat ai.bstein.dev/role: isolated-user-chat
ai.bstein.dev/isolation: one Hermes process and PVC per Keycloak subject 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/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/agent-inject: "true"
vault.hashicorp.com/role: hermes-chat vault.hashicorp.com/role: hermes-chat
vault.hashicorp.com/agent-inject-secret-anthropic-token: kv/data/atlas/hermes/agent-tokens 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} - {name: api, containerPort: 8642, protocol: TCP}
env: env:
- {name: HERMES_HOME, value: /opt/data} - {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: HOME, value: /opt/data/home}
- {name: TERMINAL_CWD, value: /opt/data/workspace} - {name: TERMINAL_CWD, value: /opt/data/workspace}
- {name: HERMES_WRITE_SAFE_ROOT, 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} - {name: API_SERVER_CORS_ORIGINS, value: https://chat.hermes.bstein.dev}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {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} - {name: auth-patch, mountPath: /opt/hermes/hermes_cli/auth.py, subPath: auth.py}
readinessProbe: readinessProbe:
tcpSocket: {port: api} tcpSocket: {port: api}
@ -228,7 +228,7 @@ spec:
- {name: webui, containerPort: 8787, protocol: TCP} - {name: webui, containerPort: 8787, protocol: TCP}
env: env:
- {name: HERMES_HOME, value: /opt/data} - {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: HOME, value: /opt/data/home}
- {name: HERMES_WEBUI_AGENT_DIR, value: /opt/hermes} - {name: HERMES_WEBUI_AGENT_DIR, value: /opt/hermes}
- {name: HERMES_WEBUI_HOST, value: 0.0.0.0} - {name: HERMES_WEBUI_HOST, value: 0.0.0.0}
@ -245,7 +245,7 @@ spec:
- {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"} - {name: HERMES_WEBUI_TRUST_FORWARDED_PROTO, value: "1"}
volumeMounts: volumeMounts:
- {name: home, mountPath: /opt/data} - {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} - {name: tmp, mountPath: /tmp}
readinessProbe: readinessProbe:
httpGet: {path: /health, port: webui} httpGet: {path: /health, port: webui}

View File

@ -9,6 +9,43 @@ data:
brad@bstein.dev brad@bstein.dev
--- ---
apiVersion: v1 apiVersion: v1
kind: ConfigMap
metadata:
name: hermes-chat-oauth-templates
namespace: hermes
data:
error.html: |
{{define "error.html"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.StatusCode}} {{.Title}}</title>
{{if eq .StatusCode 500}}<meta http-equiv="refresh" content="0;url={{.ProxyPrefix}}/sign_in?rd=/">{{end}}
<style>
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f5f5;color:#333;font:16px/1.5 system-ui,sans-serif}
main{width:min(560px,calc(100% - 40px));box-sizing:border-box;padding:36px;border:1px solid #ddd;border-radius:14px;background:#fff;text-align:center;box-shadow:0 12px 45px #0002}
h1{font-size:3rem;margin:.2rem}.detail{color:#666}a{display:inline-block;margin-top:18px;padding:10px 16px;border-radius:8px;background:#00bfa5;color:#fff;text-decoration:none}
</style>
</head>
<body>
<main>
{{if eq .StatusCode 500}}
<h1>Signing you back in…</h1>
<p class="detail">The previous one-time login callback was already used. Hermes is starting a fresh sign-in automatically.</p>
<a href="{{.ProxyPrefix}}/sign_in?rd=/">Continue now</a>
{{else}}
<h1>{{.StatusCode}} {{.Title}}</h1>
{{if .Message}}<p class="detail">{{.Message}}</p>{{end}}
<a href="{{.ProxyPrefix}}/sign_in?rd=/">Sign in again</a>
{{end}}
</main>
</body>
</html>
{{end}}
---
apiVersion: v1
kind: Service kind: Service
metadata: metadata:
name: oauth2-proxy-hermes-agent name: oauth2-proxy-hermes-agent
@ -239,6 +276,7 @@ spec:
labels: labels:
app: oauth2-proxy-hermes-chat app: oauth2-proxy-hermes-chat
annotations: annotations:
ai.bstein.dev/config-rev: "20260809-consumed-callback-recovery"
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/role: hermes-chat vault.hashicorp.com/role: hermes-chat
@ -275,6 +313,7 @@ spec:
- --cookie-samesite=lax - --cookie-samesite=lax
- --cookie-refresh=1h - --cookie-refresh=1h
- --cookie-expire=8h - --cookie-expire=8h
- --custom-templates-dir=/etc/oauth2-proxy/templates
- '--skip-auth-route=GET=^/sw[.]js([?].*)?$' - '--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
@ -302,7 +341,11 @@ spec:
requests: {cpu: 25m, memory: 64Mi} requests: {cpu: 25m, memory: 64Mi}
limits: {cpu: 250m, memory: 256Mi} limits: {cpu: 250m, memory: 256Mi}
volumeMounts: volumeMounts:
- {name: templates, mountPath: /etc/oauth2-proxy/templates, readOnly: true}
- {name: tmp, mountPath: /tmp} - {name: tmp, mountPath: /tmp}
volumes: volumes:
- name: templates
configMap:
name: hermes-chat-oauth-templates
- name: tmp - name: tmp
emptyDir: {sizeLimit: 64Mi} emptyDir: {sizeLimit: 64Mi}

View File

@ -252,7 +252,8 @@ func privateWorkspaceReadAllowed(method, path string) bool {
return false return false
} }
switch path { 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 return true
default: default:
return false return false

View File

@ -163,6 +163,9 @@ func TestRouterAllowsPrivateWorkspaceReadsButBlocksRegistration(t *testing.T) {
"/api/file", "/api/file",
"/api/file/raw", "/api/file/raw",
"/api/folder/download", "/api/folder/download",
"/api/logs",
"/api/rollback/list",
"/api/rollback/diff",
} { } {
request := httptest.NewRequest(http.MethodGet, path, nil) request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("X-Forwarded-User", "subject") 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) { func TestWebUIModelAndReasoningOverridesAreProxied(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.Method != http.MethodPost { if request.Method != http.MethodPost {

View File

@ -17,7 +17,7 @@ const telegramPage = `<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes on Telegram</title> <title>Hermes on Telegram</title>
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-2"> <link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-3">
</head> </head>
<body class="hermes-link-page"> <body class="hermes-link-page">
<main class="hermes-link-card" data-telegram-page> <main class="hermes-link-card" data-telegram-page>
@ -32,32 +32,183 @@ const telegramPage = `<!doctype html>
<section id="telegram-result" hidden></section> <section id="telegram-result" hidden></section>
<p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p> <p class="hermes-fine-print">Codes expire after 10 minutes. Only direct messages are accepted; group messages are ignored.</p>
</main> </main>
<script src="/hermes-chat-bridge.js?v=20260809-2" defer></script> <script src="/hermes-chat-bridge.js?v=20260809-3" defer></script>
</body>
</html>`
const privateFilesPage = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Hermes Private Files</title>
<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-3">
</head>
<body class="hermes-files-page">
<main class="hermes-files-shell" data-files-page>
<header class="hermes-files-header">
<div>
<a id="files-back" class="hermes-back" href="/"> Back to Hermes</a>
<h1>Private files</h1>
<p>Files created by Hermes in your isolated 10 GiB Home workspace.</p>
</div>
<label>Conversation <select id="files-session"></select></label>
</header>
<nav id="files-breadcrumbs" class="hermes-breadcrumbs" aria-label="Folder path"></nav>
<p id="files-status">Loading your workspace</p>
<div class="hermes-files-grid">
<section>
<div class="hermes-files-toolbar">
<strong>Files and folders</strong>
<a id="files-download-folder" class="hermes-button" href="#">Download folder</a>
</div>
<ul id="files-list" class="hermes-file-list"></ul>
</section>
<section class="hermes-file-viewer">
<div class="hermes-files-toolbar">
<strong id="file-title">Select a file</strong>
<a id="file-download" class="hermes-button" href="#" hidden>Download</a>
</div>
<pre id="file-content">Choose a file to preview it here.</pre>
</section>
</div>
</main>
<script src="/hermes-chat-bridge.js?v=20260809-3" defer></script>
</body> </body>
</html>` </html>`
const bridgeCSS = ` 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-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{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 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} .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} #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 bridgeJS = `(() => {
const page = document.querySelector('[data-telegram-page]'); const page = document.querySelector('[data-telegram-page]');
if (!page) { const filesPage = document.querySelector('[data-files-page]');
if (!document.getElementById('hermes-telegram-shortcut')) { const hideChatAdministration = () => {
const link = document.createElement('a'); document.querySelectorAll('[data-panel="kanban"]').forEach((node) => { node.hidden = true; });
link.id = 'hermes-telegram-shortcut'; };
link.href = '/telegram'; hideChatAdministration();
link.textContent = 'Telegram'; if (!page && !filesPage) {
link.setAttribute('aria-label', 'Connect Hermes to Telegram'); if (!document.getElementById('hermes-chat-shortcuts')) {
document.body.appendChild(link); 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; 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 status = document.getElementById('telegram-status');
const result = document.getElementById('telegram-result'); const result = document.getElementById('telegram-result');
const linkButton = document.getElementById('telegram-link'); 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'") 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) _, _ = io.WriteString(writer, telegramPage)
return true 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": case "/api/telegram/status":
if request.Method != http.MethodGet { if request.Method != http.MethodGet {
writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
@ -222,8 +383,8 @@ func injectChatBridge(response *http.Response) error {
_ = response.Body.Close() _ = response.Body.Close()
content := string(body) content := string(body)
if !strings.Contains(content, "hermes-chat-bridge.js") { if !strings.Contains(content, "hermes-chat-bridge.js") {
content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-2"></head>`, 1) content = strings.Replace(content, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-3"></head>`, 1)
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260809-2" defer></script></body>`, 1) content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260809-3" defer></script></body>`, 1)
} }
response.Body = io.NopCloser(strings.NewReader(content)) response.Body = io.NopCloser(strings.NewReader(content))
response.ContentLength = int64(len(content)) response.ContentLength = int64(len(content))

View File

@ -33,7 +33,9 @@ def test_chat_config_enables_real_research_compute_and_delegation():
for platform in ("cli", "api_server"): for platform in ("cli", "api_server"):
toolsets = config["platform_toolsets"][platform] toolsets = config["platform_toolsets"][platform]
assert "delegation" in toolsets assert "delegation" in toolsets
assert "browser" in toolsets
assert "python_sandbox" in toolsets assert "python_sandbox" in toolsets
assert "vision" in toolsets
assert "web" in toolsets assert "web" in toolsets
assert "terminal" not in toolsets assert "terminal" not in toolsets
assert "code_execution" 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"] args = deployment["spec"]["template"]["spec"]["containers"][0]["args"]
assert "--skip-auth-route=GET=^/sw[.]js([?].*)?$" in 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): def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch):

View File

@ -239,3 +239,10 @@ def test_agent_coordinator_has_a_long_running_tool_budget():
assert config["agent"]["max_turns"] == 180 assert config["agent"]["max_turns"] == 180
assert config["tool_loop_guardrails"]["hard_stop_enabled"] is True 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