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:
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user