fix(hermes): enable browser tools and private files
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 172
Some checks failed
Tests / Declarative: Post Actions failed: 2, passed: 172
This commit is contained in:
parent
6576a64143
commit
36399fce6e
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user