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:
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

View File

@ -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

View File

@ -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.

View File

@ -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}

View File

@ -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}

View File

@ -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"}}
<!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
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}

View File

@ -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

View File

@ -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 {

View File

@ -17,7 +17,7 @@ const telegramPage = `<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<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>
<body class="hermes-link-page">
<main class="hermes-link-card" data-telegram-page>
@ -32,32 +32,183 @@ const telegramPage = `<!doctype html>
<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>
</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>
</html>`
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, "</head>", `<link rel="stylesheet" href="/hermes-chat-bridge.css?v=20260809-2"></head>`, 1)
content = strings.Replace(content, "</body>", `<script src="/hermes-chat-bridge.js?v=20260809-2" defer></script></body>`, 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-3" defer></script></body>`, 1)
}
response.Body = io.NopCloser(strings.NewReader(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"):
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):

View File

@ -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