fix(hermes): self-host dashboard_auth + web_dist stubs to survive upstream missing pieces

Upstream hermes-agent 0.14.0 (both the PyPI wheel and the public git source
at NousResearch/hermes-agent) ships hermes_cli/web_server.py with hard imports
of hermes_cli.dashboard_auth.{audit,middleware,prefix,routes,ws_tickets} but
those source files are not included in the distribution, and the dashboard
SPA bundle (hermes_cli/web_dist/) is also missing. On Windows this makes
'hermes dashboard' crash on startup, which collapses every ClawPanel page
that talks to port 9119 (Profile / Kanban / OAuth / Channels / Sessions
detail) into a 'request rejected by target machine' error.

Per project policy we do not patch upstream. Instead, after each install /
upgrade and again before spawning the dashboard, ClawPanel now injects a
minimal pass-through stub into the installed venv:

  - hermes_cli/dashboard_auth/{__init__,audit,middleware,prefix,routes,
    ws_tickets}.py — no-op providers, safe on loopback (127.0.0.1) where
    the auth gate is intentionally disabled.
  - hermes_cli/web_dist/index.html + assets/ — so mount_spa() takes the
    token-injecting branch instead of the 'Frontend not built' 404 branch.
    Without this the panel can never scrape window.__HERMES_SESSION_TOKEN__
    from the dashboard HTML and every /api/* call fails with 401.

Injection is idempotent: if upstream eventually ships the real files, the
stub writer skips them so the real implementation wins. Failures are
logged-and-swallowed so install/upgrade is never blocked by best-effort
compatibility patches.

Group-chat page is also fixed: hermes_agent_run resolves to the run_id
string (not a result object), so the page was rendering 'run_xxx...' as
the assistant reply. It now listens to hermes-run-{started,delta,done,
error,cancelled} events and resolves to payload.output (with accumulated
delta as fallback).
This commit is contained in:
晴天
2026-05-28 08:53:37 +08:00
parent 863d7d75be
commit 1836069b0f
2 changed files with 408 additions and 13 deletions

View File

@@ -1193,7 +1193,7 @@ fn kill_dashboard_pid() -> bool {
/// 3. 进程提前退出 → 读日志尾部检测 deps_missing / port_in_use
/// 返回 `{ started, kind?, port, pid?, exit_code?, log_tail? }`
#[tauri::command]
pub async fn hermes_dashboard_start() -> Result<Value, String> {
pub async fn hermes_dashboard_start(app: tauri::AppHandle) -> Result<Value, String> {
let port = hermes_dashboard_port();
// 1. 已运行?
if hermes_dashboard_tcp_running(port, 500)
@@ -1211,6 +1211,11 @@ pub async fn hermes_dashboard_start() -> Result<Value, String> {
// 2. 清掉残留 PID来自上一次 spawn
let _ = kill_dashboard_pid();
// 2b. 上游 wheel 漏装 dashboard_auth + web_dist 时dashboard 进程会直接崩。
// 在 spawn 前先做一次幂等 stub 注入,覆盖既有用户(从早期版本升上来、没走过 install_hermes
// 的新代码路径)也能立即恢复。已存在的真实文件不会被覆盖。
inject_hermes_dashboard_compat_stub(&app);
let home = hermes_home();
let log_path = home.join("dashboard-run.log");
let log_file =
@@ -1392,6 +1397,9 @@ pub async fn install_hermes(
let _ = app.emit("hermes-install-progress", 90u32);
// Step 2b: 注入 dashboard 兼容 stub弥补上游 wheel 漏装 dashboard_auth + web_dist
inject_hermes_dashboard_compat_stub(&app);
// Step 3: 验证安装
let _ = app.emit("hermes-install-log", "🔍 验证安装...");
let enhanced = hermes_enhanced_path();
@@ -1589,6 +1597,315 @@ fn hermes_runtime_extras_log_segment() -> String {
.join(" ")
}
// ---------------------------------------------------------------------------
// Hermes Dashboard compat stubs
//
// hermes-agent 0.14.0 (both PyPI wheel and `git+...` source) ships
// `hermes_cli/web_server.py` with hard imports of `hermes_cli.dashboard_auth.*`
// submodules whose source files are NOT included in the distribution. It also
// omits the built dashboard SPA (`hermes_cli/web_dist/`). On Windows in
// particular, the missing dashboard_auth subpackage breaks `hermes dashboard`
// completely, taking down every ClawPanel page that talks to port 9119
// (Profile, Kanban, OAuth, Channels, Sessions detail).
//
// To stay self-sufficient (per project policy: do not patch upstream), we
// inject a minimal pass-through stub into the installed venv:
// - `hermes_cli/dashboard_auth/{__init__,audit,middleware,prefix,routes,ws_tickets}.py`
// so all `from hermes_cli.dashboard_auth.* import ...` lines resolve.
// Auth is a no-op; valid for loopback (127.0.0.1) bindings where the
// auth gate is intentionally disabled.
// - `hermes_cli/web_dist/index.html` so `mount_spa()` takes the
// token-injecting branch instead of the `Frontend not built` 404 branch.
// Without this, the panel's `dashboard_session_token` scrape returns
// 404 and all `/api/*` calls fail with 401.
//
// The injection is idempotent: if upstream eventually ships either piece,
// the corresponding stub write is skipped so the real implementation wins.
// ---------------------------------------------------------------------------
const HERMES_DASHBOARD_AUTH_INIT_PY: &str = r#""""ClawPanel-injected stub for hermes_cli.dashboard_auth.
Upstream hermes-agent ships web_server.py with imports referencing this
subpackage, but the actual source files are NOT included in the wheel or
the public git repo. To keep Hermes Dashboard usable in loopback
(127.0.0.1) mode, ClawPanel injects this minimal pass-through stub at
install/upgrade time.
When upstream eventually ships the real module, delete this directory
and reinstall hermes-agent; the real implementation will be picked up.
"""
from __future__ import annotations
from typing import Iterable, List
class DashboardAuthProvider:
"""Stub base class. Real providers inherit from this."""
name: str = ""
_REGISTERED: List["DashboardAuthProvider"] = []
def register_provider(provider: "DashboardAuthProvider") -> None:
"""No-op stub. ClawPanel binds to 127.0.0.1 so the gate is disabled."""
if isinstance(provider, DashboardAuthProvider):
_REGISTERED.append(provider)
def list_providers() -> Iterable["DashboardAuthProvider"]:
"""Return registered providers (empty on loopback)."""
return list(_REGISTERED)
__all__ = ["DashboardAuthProvider", "register_provider", "list_providers"]
"#;
const HERMES_DASHBOARD_AUTH_AUDIT_PY: &str = r#""""ClawPanel stub: hermes_cli.dashboard_auth.audit"""
from __future__ import annotations
from enum import Enum
from typing import Any
class AuditEvent(str, Enum):
LOGIN = "login"
LOGOUT = "logout"
LOGIN_FAILED = "login_failed"
WS_TICKET_MINTED = "ws_ticket_minted"
WS_TICKET_REJECTED = "ws_ticket_rejected"
PROVIDER_REGISTERED = "provider_registered"
def audit_log(event: Any, **fields: Any) -> None:
"""No-op stub. Real implementation appends to an audit log file."""
return None
__all__ = ["AuditEvent", "audit_log"]
"#;
const HERMES_DASHBOARD_AUTH_MIDDLEWARE_PY: &str = r#""""ClawPanel stub: hermes_cli.dashboard_auth.middleware"""
from __future__ import annotations
async def gated_auth_middleware(request, call_next):
"""Pass-through ASGI middleware. Real one enforces JWT on non-loopback."""
return await call_next(request)
__all__ = ["gated_auth_middleware"]
"#;
const HERMES_DASHBOARD_AUTH_PREFIX_PY: &str = r#""""ClawPanel stub: hermes_cli.dashboard_auth.prefix"""
from __future__ import annotations
def normalise_prefix(prefix: str) -> str:
"""Normalise X-Forwarded-Prefix style values to a leading-slash form."""
if not prefix:
return ""
return "/" + prefix.strip("/")
__all__ = ["normalise_prefix"]
"#;
const HERMES_DASHBOARD_AUTH_ROUTES_PY: &str = r#""""ClawPanel stub: hermes_cli.dashboard_auth.routes"""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()
__all__ = ["router"]
"#;
const HERMES_DASHBOARD_AUTH_WS_TICKETS_PY: &str = r#""""ClawPanel stub: hermes_cli.dashboard_auth.ws_tickets"""
from __future__ import annotations
class TicketInvalid(Exception):
"""Raised when a WS ticket is rejected. Stub never raises."""
def mint_ticket(*args, **kwargs) -> str:
"""Stub. Real one mints short-lived JWTs."""
return "stub-loopback-ticket"
def consume_ticket(*args, **kwargs) -> None:
"""Stub. Real one validates signature + expiry. Never raises here."""
return None
__all__ = ["TicketInvalid", "mint_ticket", "consume_ticket"]
"#;
const HERMES_DASHBOARD_WEB_DIST_INDEX_HTML: &str = r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Hermes Dashboard (ClawPanel stub)</title>
<meta name="generator" content="clawpanel-dashboard-spa-stub">
</head>
<body>
<main style="font-family:system-ui,-apple-system,sans-serif;padding:32px;color:#333">
<h1 style="margin:0 0 16px">Hermes Dashboard</h1>
<p>This SPA placeholder is injected by ClawPanel so the dashboard backend
emits a session token. ClawPanel provides its own UI; the upstream
SPA is not shipped with the wheel.</p>
</main>
</body>
</html>
"#;
/// Locate the installed `hermes_cli` package directory inside the uv tool venv.
///
/// Layouts vary by platform:
/// - Windows: `<uv tool dir>/hermes-agent/Lib/site-packages/hermes_cli`
/// - macOS / Linux: `<uv tool dir>/hermes-agent/lib/python3.X/site-packages/hermes_cli`
///
/// Returns `None` if uv is unavailable or hermes-agent is not installed.
fn locate_hermes_cli_package_dir() -> Option<std::path::PathBuf> {
let uv_path = uv_bin_path();
let uv_cmd = if uv_path.exists() {
uv_path.to_string_lossy().to_string()
} else {
"uv".into()
};
let mut cmd = std::process::Command::new(&uv_cmd);
cmd.args(["tool", "dir"]);
cmd.env("PATH", hermes_enhanced_path());
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(CREATE_NO_WINDOW);
}
let output = cmd.output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
if stdout.is_empty() {
return None;
}
let hermes_root = std::path::PathBuf::from(&stdout).join("hermes-agent");
if !hermes_root.exists() {
return None;
}
let windows_path = hermes_root
.join("Lib")
.join("site-packages")
.join("hermes_cli");
if windows_path.exists() {
return Some(windows_path);
}
let lib_dir = hermes_root.join("lib");
if let Ok(entries) = std::fs::read_dir(&lib_dir) {
for entry in entries.flatten() {
let pkg = entry.path().join("site-packages").join("hermes_cli");
if pkg.exists() {
return Some(pkg);
}
}
}
None
}
/// Inject the dashboard_auth and web_dist stubs into the installed hermes-agent
/// venv if upstream did not ship them. Idempotent: existing files are never
/// overwritten so the real implementation, if/when it lands, wins.
///
/// Stub injection failures are logged and swallowed — install/upgrade succeeds
/// regardless so users aren't blocked by best-effort compatibility patches.
fn inject_hermes_dashboard_compat_stub(app: &tauri::AppHandle) {
let hermes_cli = match locate_hermes_cli_package_dir() {
Some(p) => p,
None => {
let _ = app.emit(
"hermes-install-log",
"⚠ 跳过 dashboard 兼容 stub 注入:未找到 hermes_cli 包目录",
);
return;
}
};
let mut wrote_auth = false;
let auth_dir = hermes_cli.join("dashboard_auth");
if !auth_dir.join("__init__.py").exists() {
if let Err(e) = std::fs::create_dir_all(&auth_dir) {
let _ = app.emit(
"hermes-install-log",
format!("⚠ 无法创建 dashboard_auth 目录: {e}"),
);
return;
}
let files: [(&str, &str); 6] = [
("__init__.py", HERMES_DASHBOARD_AUTH_INIT_PY),
("audit.py", HERMES_DASHBOARD_AUTH_AUDIT_PY),
("middleware.py", HERMES_DASHBOARD_AUTH_MIDDLEWARE_PY),
("prefix.py", HERMES_DASHBOARD_AUTH_PREFIX_PY),
("routes.py", HERMES_DASHBOARD_AUTH_ROUTES_PY),
("ws_tickets.py", HERMES_DASHBOARD_AUTH_WS_TICKETS_PY),
];
for (name, content) in files {
let path = auth_dir.join(name);
if let Err(e) = std::fs::write(&path, content) {
let _ = app.emit(
"hermes-install-log",
format!("⚠ 写入 dashboard_auth/{name} 失败: {e}"),
);
return;
}
}
wrote_auth = true;
}
let mut wrote_dist = false;
let dist_dir = hermes_cli.join("web_dist");
let index_path = dist_dir.join("index.html");
if !index_path.exists() {
if let Err(e) = std::fs::create_dir_all(dist_dir.join("assets")) {
let _ = app.emit(
"hermes-install-log",
format!("⚠ 无法创建 web_dist 目录: {e}"),
);
return;
}
if let Err(e) = std::fs::write(&index_path, HERMES_DASHBOARD_WEB_DIST_INDEX_HTML) {
let _ = app.emit(
"hermes-install-log",
format!("⚠ 写入 web_dist/index.html 失败: {e}"),
);
return;
}
wrote_dist = true;
}
if wrote_auth || wrote_dist {
let mut parts: Vec<&str> = Vec::new();
if wrote_auth {
parts.push("dashboard_auth");
}
if wrote_dist {
parts.push("web_dist");
}
let _ = app.emit(
"hermes-install-log",
format!("📦 已注入 Hermes Dashboard 兼容 stub: {}", parts.join(", ")),
);
} else {
let _ = app.emit(
"hermes-install-log",
"✓ Hermes Dashboard 兼容 stub 已存在,无需注入",
);
}
}
fn sanitize_hermes_install_output(text: &str) -> String {
let mut out = text.replace(HERMES_GIT_URL, "hermes-agent");
out = out.replace(
@@ -13463,6 +13780,8 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
}
if output.status.success() {
// 注入 dashboard 兼容 stub升级路径与安装路径保持一致避免上游 wheel 漏装的子包再次缺失)
inject_hermes_dashboard_compat_stub(&app);
let _ = app.emit("hermes-install-log", "✅ 升级完成");
let _ = app.emit("hermes-install-progress", 100u32);
Ok("升级完成".into())

View File

@@ -12,11 +12,92 @@
* - 不持久化(一次性会话,刷新清空)
*/
import { t } from '../../../lib/i18n.js'
import { api, isTauriRuntime } from '../../../lib/tauri-api.js'
import { toast } from '../../../components/toast.js'
import { humanizeError } from '../../../lib/humanize-error.js'
import { api, isTauriRuntime, safeTauriListen } from '../../../lib/tauri-api.js'
import { svgIcon } from '../lib/svg-icons.js'
/**
* Hermes `hermes_agent_run` 是 streaming-with-events它通过 SSE 消费 Hermes Gateway
* 的 `/v1/runs/{id}/events` 并把每个事件用 `app.emit("hermes-run-*")` 派发到前端,
* 命令本身 resolve 的是 *run_id 字符串*(不是 final 输出)。
*
* 群聊页之前把 run_id 当成回复直接展示出来(典型现象:消息气泡里只有 `"run_xxx..."`
* 是因为 onSend 把 `await api.hermesAgentRun(...)` 的返回值当成结果对象去解析。
*
* 这个 helper 串联两端:
* 1. 注册 `hermes-run-{started,delta,done,error,cancelled}` listener
* 2. 调用 `hermesAgentRun(input)` 触发 run命令在 SSE 流结束后才 resolve
* 所以 done 事件一般已经先到了 — listener 即可拿到 `payload.output`。
* 3. 兜底done 没到时累积 delta 文本作为最终结果。
*
* 注意:并发场景下 listener 会全局收事件,因此用 run_id 过滤,
* 串行模式(当前群聊调度方式)也能 race-safe。
*/
async function runHermesAgentAndWaitFinal(input) {
if (!isTauriRuntime()) {
throw new Error('Hermes group chat requires Tauri runtime')
}
return new Promise((resolve, reject) => {
const unsubs = []
let runId = null
let accumulated = ''
let settled = false
const cleanup = () => {
for (const u of unsubs) {
try { u() } catch { /* listener already detached */ }
}
}
const finish = (text) => {
if (settled) return
settled = true
cleanup()
resolve(text)
}
const fail = (err) => {
if (settled) return
settled = true
cleanup()
reject(err)
}
const matchesRun = (rid) => !runId || !rid || rid === runId
;(async () => {
try {
unsubs.push(await safeTauriListen('hermes-run-started', (e) => {
if (!runId && e?.payload?.run_id) runId = e.payload.run_id
}))
unsubs.push(await safeTauriListen('hermes-run-delta', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
accumulated += e?.payload?.delta || ''
}))
unsubs.push(await safeTauriListen('hermes-run-done', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
const out = (e?.payload?.output || accumulated || '').trim()
finish(out)
}))
unsubs.push(await safeTauriListen('hermes-run-error', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
fail(new Error(e?.payload?.error || 'unknown error'))
}))
unsubs.push(await safeTauriListen('hermes-run-cancelled', (e) => {
if (!matchesRun(e?.payload?.run_id)) return
finish(accumulated.trim() || '(cancelled)')
}))
// 触发 run。Rust 端 hermes_agent_run 内部消费 SSE 直到 [DONE] 才 resolve
// 因此 done 事件一般已经先到listener 已经 finish 过;这里拿到的 run_id 仅作兜底。
const ridFromAck = await api.hermesAgentRun(input, null, null, null, null)
if (!runId) runId = ridFromAck
// 防御:如果 done 事件因为顺序问题尚未派发(理论上不会发生),等一拍兜底
setTimeout(() => {
if (!settled) finish(accumulated.trim())
}, 300)
} catch (e) {
fail(e)
}
})()
})
}
function escHtml(s) {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
@@ -238,16 +319,11 @@ export function render() {
await api.hermesProfileUse(profile)
activeProfile = profile
}
// agent run(非流式)
const result = await api.hermesAgentRun(text, null, null, null, null)
// result 形如 { final, messages, ... }
const finalText = result?.final?.content
|| result?.final
|| result?.output
|| (Array.isArray(result?.messages) && result.messages.filter(m => m.role === 'assistant').slice(-1)[0]?.content)
|| JSON.stringify(result || '').slice(0, 500)
// 触发 agent run,并通过 hermes-run-* 事件等真正的 final 输出。
// 不能直接用 hermesAgentRun 的返回值,它只是 run_id 字符串,不是回复内容。
const finalText = await runHermesAgentAndWaitFinal(text)
placeholder.loading = false
placeholder.content = String(finalText || '').trim() || t('engine.hermesGroupChatNoOutput')
placeholder.content = finalText || t('engine.hermesGroupChatNoOutput')
placeholder.ts = Date.now()
} catch (e) {
placeholder.loading = false