mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-30 04:40:18 +08:00
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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user