"use strict"; /** * HuggingMes + Hermes WebUI — single-port router on HF Space port 7861. * * Routes: * /login -> HuggingMes login (password = GATEWAY_TOKEN) * /health /status -> JSON health (unauthenticated — for HF probes + keepalive) * /hm /hm/* -> HuggingMes status page + app (auth-gated) * /hmd /hmd/* -> Hermes dashboard passthrough for off-Space * workspaces (no router auth — dashboard's own * session token gates writes; opt-in by URL) * /dashboard -> redirect to /hm * /v1 /v1/* -> Hermes gateway (bearer auth; HTML => login redirect) * /telegram /telegram/*-> Telegram webhook (unauthenticated; Telegram needs to reach it) * everything else -> Hermes WebUI (nesquena/hermes-webui) as the primary UI * WebUI handles its own login at /login-... no, wait: WebUI * also exposes /login. We keep HuggingMes' login at /login * so the shared GATEWAY_TOKEN gates both. * * Based on github.com/somratpro/HuggingMes with added WebUI routing as the * primary UI. */ const http = require("http"); const fs = require("fs"); const net = require("net"); const crypto = require("crypto"); const PORT = Number(process.env.PORT || 7861); const GATEWAY_PORT = Number(process.env.API_SERVER_PORT || 8642); const DASHBOARD_PORT = Number(process.env.DASHBOARD_PORT || 9119); const TELEGRAM_WEBHOOK_PORT = Number(process.env.TELEGRAM_WEBHOOK_PORT || 8765); const WEBUI_PORT = Number(process.env.HERMES_WEBUI_PORT || 8787); const GATEWAY_HOST = "127.0.0.1"; const startTime = Date.now(); const API_SERVER_KEY = process.env.API_SERVER_KEY || ""; const HM_PREFIX = "/hm"; // Dashboard passthrough for off-Space workspaces (e.g. hermes-workspace // running on a laptop). Anything under /hmd/* is forwarded directly to the // internal dashboard with no router-level auth — the dashboard's own // ephemeral session token is the only gate. This is intentional: the // workspace scrapes that token from /hmd/ and then sends it as the bearer // on /hmd/api/* requests, exactly mirroring the dashboard's normal flow. // // Implication: anyone who can reach this Space's URL can call the dashboard // API (sessions, skills, config). If you don't need remote workspace access, // don't share the Space URL or set up an upstream auth layer. const HMD_PREFIX = "/hmd"; const LOGIN_PATH = "/hm/login"; const SESSION_COOKIE = "huggingmes_session"; const PRIMARY_UI = (process.env.PRIMARY_UI || "webui").toLowerCase(); const SYNC_STATUS_FILE = "/tmp/huggingmes-sync-status.json"; const CLOUDFLARE_KEEPALIVE_STATUS_FILE = "/tmp/huggingmes-cloudflare-keepalive-status.json"; // Reuse loopback connections to internal backends. Node's global agent // defaults to keepAlive:false, so every proxied request opened a fresh TCP // connection and closed it after the response — a lot of handshakes for a // chat UI making many /api/* and /v1/* calls per session. const internalAgent = new http.Agent({ keepAlive: true, maxSockets: 64, timeout: 30000, }); /* ── Port probing + auth ──────────────────────────────────────────── */ /** * HTTP-level liveness probe. A bare TCP connect (the old canConnect) only * verifies a socket accepts — a wedged backend that opens the socket but never * responds still passes. This issues a tiny GET and treats a connection * error, timeout, or non-2xx/3xx as down. Falls back to a plain TCP connect * if the backend doesn't speak HTTP on '/' (some internal services don't). */ function httpProbe(port, host = GATEWAY_HOST, timeoutMs = 800) { return new Promise((resolve) => { let settled = false; const finish = (ok) => { if (settled) return; settled = true; try { req.destroy(); } catch {} resolve(ok); }; const req = http.get( { hostname: host, port, path: "/", timeout: timeoutMs }, (res) => { // Any HTTP response (even 404) means the backend is alive and // speaking HTTP — a wedged process wouldn't have responded. finish(res.statusCode != null); }, ); req.on("timeout", () => finish(false)); req.on("error", () => { // Non-HTTP service or connection refused. Fall back to a TCP probe // so we don't false-negative a backend that's up but doesn't serve /. const socket = net.createConnection({ port, host }); const tcpDone = (ok) => { socket.removeAllListeners(); socket.destroy(); finish(ok); }; socket.setTimeout(timeoutMs); socket.once("connect", () => tcpDone(true)); socket.once("timeout", () => tcpDone(false)); socket.once("error", () => tcpDone(false)); }); }); } /** @deprecated use httpProbe — kept for any external callers. */ function canConnect(port, host = GATEWAY_HOST, timeoutMs = 600) { return new Promise((resolve) => { const socket = net.createConnection({ port, host }); const done = (ok) => { socket.removeAllListeners(); socket.destroy(); resolve(ok); }; socket.setTimeout(timeoutMs); socket.once("connect", () => done(true)); socket.once("timeout", () => done(false)); socket.once("error", () => done(false)); }); } function readJson(path, fallback = null) { try { if (fs.existsSync(path)) return JSON.parse(fs.readFileSync(path, "utf8")); } catch {} return fallback; } function timingSafeEqualString(left, right) { if (!left || !right) return false; const a = Buffer.from(left); const b = Buffer.from(right); if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } function expectedSessionValue() { if (!API_SERVER_KEY) return ""; return crypto .createHmac("sha256", API_SERVER_KEY) .update("huggingmes-session-v1") .digest("hex"); } function parseCookies(req) { const header = req.headers.cookie || ""; const cookies = {}; for (const item of header.split(";")) { const sep = item.indexOf("="); if (sep < 0) continue; const name = item.slice(0, sep).trim(); const value = item.slice(sep + 1).trim(); if (!name) continue; try { cookies[name] = decodeURIComponent(value); } catch { cookies[name] = value; } } return cookies; } function isHttpsRequest(req) { return req.headers["x-forwarded-proto"] === "https"; } function buildSessionCookie(req) { const secure = isHttpsRequest(req) ? "; Secure" : ""; return `${SESSION_COOKIE}=${encodeURIComponent(expectedSessionValue())}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400${secure}`; } function getBearerToken(req) { const value = req.headers.authorization || ""; const match = /^Bearer\s+(.+)$/i.exec(value); return match ? match[1].trim() : ""; } function isAuthorized(req) { if (!API_SERVER_KEY) return true; return ( timingSafeEqualString(getBearerToken(req), API_SERVER_KEY) || timingSafeEqualString( parseCookies(req)[SESSION_COOKIE], expectedSessionValue(), ) ); } /** * WebSocket Origin allowlist. Browsers send Origin on WS upgrades; we must * validate it to prevent Cross-Site WebSocket Hijacking (CSWSH) — otherwise a * malicious site can open a WS to the Space using the user's cookies. The * allowlist is the Space's public host(s): x-forwarded-host (HF Spaces), * SPACE_HOST, plus any explicit ALLOWED_WS_ORIGINS (comma-separated). */ function allowedWsOrigin(req) { const raw = String(req.headers.origin || ""); if (!raw) return true; // non-browser WS clients (desktop app) omit Origin let parsed; try { parsed = new URL(raw); } catch { return false; } // Electron's internal pages use file:// origins — the desktop app is a // trusted client, so allow file:// unconditionally. if (parsed.protocol === "file:") return true; const host = parsed.host.toLowerCase(); // includes port, e.g. "localhost:5173" const hostname = parsed.hostname.toLowerCase(); // no port, e.g. "localhost" if (!host && !hostname) return false; const allowed = new Set(); const xfh = String(req.headers["x-forwarded-host"] || "").trim().toLowerCase(); if (xfh) allowed.add(xfh); const spaceHost = String(process.env.SPACE_HOST || "").trim().toLowerCase(); if (spaceHost) allowed.add(spaceHost); const explicit = String(process.env.ALLOWED_WS_ORIGINS || "").toLowerCase(); for (const part of explicit.split(",")) { const h = part.trim(); if (h) allowed.add(h); } // localhost is always allowed (local dev / Electron desktop app's internal // http server). Match both with and without port so localhost:5173 passes. allowed.add("localhost"); allowed.add("127.0.0.1"); allowed.add("0.0.0.0"); if (allowed.has(host) || allowed.has(hostname)) return true; // Also match any localhost: or 127.0.0.1: explicitly. if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0") { return true; } return false; } function sanitizeNext(value, fallback = "/") { if (!value || typeof value !== "string") return fallback; // Block protocol-relative "//evil.com" AND backslash-prefixed "/\evil.com" // (browsers normalize backslash to slash, so "/\evil.com" navigates to // "//evil.com" — a classic open-redirect bypass of the "//" check). if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) { return fallback; } // Restrict to a safe path charset; reject anything that could break out of // the Location header value (newline, quote, control chars, etc.). if (!/^\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$/.test(value)) return fallback; return value; } function loginUrl(nextPath) { return `${LOGIN_PATH}?next=${encodeURIComponent(sanitizeNext(nextPath))}`; } function wantsHtml(req) { const accept = String(req.headers.accept || ""); return accept.includes("text/html"); } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function readRequestBody(req, limit = 64 * 1024) { return new Promise((resolve, reject) => { let body = ""; req.on("data", (chunk) => { body += chunk; if (body.length > limit) { reject(new Error("Request body is too large.")); req.destroy(); } }); req.on("end", () => resolve(body)); req.on("error", reject); }); } /* ── Login page ───────────────────────────────────────────────────── */ function renderLoginPage(nextPath, errorMessage = "") { const safeNext = sanitizeNext(nextPath, "/"); const errorHtml = errorMessage ? `
${escapeHtml(errorMessage)}
` : ""; return ` HuggingMes + Hermes WebUI — Login

HuggingMes Admin

Enter the GATEWAY_TOKEN from your Space secrets to access the status dashboard.
For the Hermes chat UI, go to /.

${errorHtml}
`; } async function handleLogin(req, res, parsed) { const nextPath = sanitizeNext(parsed.searchParams.get("next") || "/", "/"); if (!API_SERVER_KEY) { redirect(res, nextPath); return; } if (req.method === "GET") { res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }); res.end(renderLoginPage(nextPath)); return; } if (req.method !== "POST") { res.writeHead(405, { allow: "GET, POST" }); res.end("Method not allowed"); return; } try { const body = await readRequestBody(req); const params = new URLSearchParams(body); const submittedToken = params.get("token") || ""; const submittedNext = sanitizeNext(params.get("next") || nextPath, "/"); if (!timingSafeEqualString(submittedToken, API_SERVER_KEY)) { res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", }); res.end( renderLoginPage( submittedNext, "That token did not match GATEWAY_TOKEN.", ), ); return; } res.writeHead(302, { location: submittedNext, "set-cookie": buildSessionCookie(req), "cache-control": "no-store", }); res.end(); } catch (error) { res.writeHead(400, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", }); res.end(error.message || "Invalid login request."); } } function requireAuth(req, res) { if (isAuthorized(req)) return true; const parsed = new URL(req.url, "http://localhost"); redirect(res, loginUrl(`${parsed.pathname}${parsed.search}`)); return false; } /* ── Upstream proxy ────────────────────────────────────────────────── */ function proxyRequest( req, res, targetPort, rewritePath = (path) => path, headerOverrides = {}, ) { const parsed = new URL(req.url, "http://localhost"); const targetPath = rewritePath(parsed.pathname) + parsed.search; const localOrigin = `http://${GATEWAY_HOST}:${targetPort}`; const headers = { ...req.headers, ...headerOverrides, host: `${GATEWAY_HOST}:${targetPort}`, origin: localOrigin, "x-forwarded-host": req.headers.host || "", "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https", }; // Python's BaseHTTPRequestHandler (used by hermes-webui and the dashboard) // cannot decode chunked request bodies — read_body() only reads via // Content-Length, and leftover chunk framing corrupts subsequent requests // on keep-alive connections (HTTP 501 with junk prepended to the method). // Buffer the full body and send it with an explicit Content-Length header // so Node.js never uses Transfer-Encoding: chunked. const hasBody = req.method === "POST" || req.method === "PUT" || req.method === "PATCH"; if (hasBody) { const chunks = []; let size = 0; const limit = 20 * 1024 * 1024; req.on("data", (chunk) => { chunks.push(chunk); size += chunk.length; if (size > limit) { req.destroy(); if (!res.headersSent) { res.writeHead(413, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "payload_too_large" })); } } }); req.on("end", () => { delete headers["transfer-encoding"]; headers["content-length"] = String(size); const proxy = http.request( { hostname: GATEWAY_HOST, port: targetPort, method: req.method, path: targetPath, headers, agent: internalAgent, }, (upstream) => { res.writeHead(upstream.statusCode || 502, upstream.headers); upstream.pipe(res); // D1: handle mid-response backend socket errors without crashing. upstream.on("error", () => { if (!res.headersSent) { try { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_error" })); } catch {} } else { try { res.destroy(); } catch {} } }); }, ); // D3: 30s timeout on the upstream request. proxy.setTimeout(30000, () => { if (!res.headersSent) { res.writeHead(504, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_timeout" })); } try { proxy.destroy(new Error("upstream_timeout")); } catch {} }); // D2: guard headersSent on the proxy error handler. proxy.on("error", (error) => { if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "proxy_error", message: error.message })); } else { try { res.destroy(); } catch {} } }); if (size > 0) proxy.write(Buffer.concat(chunks)); proxy.end(); }); req.on("error", (error) => { if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "proxy_error", message: error.message })); } }); return; } const proxy = http.request( { hostname: GATEWAY_HOST, port: targetPort, method: req.method, path: targetPath, headers, agent: internalAgent, }, (upstream) => { res.writeHead(upstream.statusCode || 502, upstream.headers); upstream.pipe(res); // D1: an unhandled 'error' on a piped IncomingMessage throws and can // crash the router. If the backend socket resets mid-response, log + // destroy the response cleanly instead of taking down every fronted // service. upstream.on("error", () => { if (!res.headersSent) { try { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_error" })); } catch {} } else { try { res.destroy(); } catch {} } }); }, ); // D3: 30s timeout so a hung backend (accepts the socket but never // responds) can't hold a request + upstream socket open forever. proxy.setTimeout(30000, () => { if (!res.headersSent) { res.writeHead(504, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_timeout" })); } try { proxy.destroy(new Error("upstream_timeout")); } catch {} }); // D2: guard headersSent so a late 'error' after headers were already // written doesn't throw ERR_HTTP_HEADERS_SENT inside the error handler. proxy.on("error", (error) => { if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "proxy_error", message: error.message })); } else { try { res.destroy(); } catch {} } }); req.pipe(proxy); } function redirect(res, location, statusCode = 302) { res.writeHead(statusCode, { location }); res.end(); } /* ── Dashboard SPA proxy with HTML rewriting ────────────────────────── * * The Hermes dashboard is a Vite React app built for root-path deployment. * Its HTML hardcodes window.__HERMES_BASE_PATH__="" and absolute src/href * paths like /assets/index-XXX.js. Under /hm/app, React's router wouldn't * know its basename and client-side routes (/config, /sessions, etc.) 404 * on refresh. * * This proxy: * - serves the dashboard's index.html for any non-asset /hm/app/* path * (SPA fallback, so /config, /profiles etc. work on direct load) * - rewrites the returned HTML so React router uses /hm/app as its * basename and absolute asset paths get prefixed with /hm/app */ function proxyDashboard(req, res) { const parsed = new URL(req.url, "http://localhost"); const inner = parsed.pathname.replace(`${HM_PREFIX}/app`, "") || "/"; const isAssetLike = inner.startsWith("/assets/") || inner.startsWith("/api/") || inner.startsWith("/dashboard-plugins/") || inner.startsWith("/ds-assets/") || /\.[a-z0-9]{1,6}$/i.test(inner); // SPA routes → serve index.html; everything else → forward as-is. const targetPath = (isAssetLike || inner === "/" ? inner : "/") + parsed.search; const headers = { ...req.headers, host: `${GATEWAY_HOST}:${DASHBOARD_PORT}`, origin: `http://${GATEWAY_HOST}:${DASHBOARD_PORT}`, "x-forwarded-host": req.headers.host || "", "x-forwarded-proto": req.headers["x-forwarded-proto"] || "https", // Disable upstream compression so we can rewrite text responses. "accept-encoding": "identity", }; const upstream = http.request( { hostname: GATEWAY_HOST, port: DASHBOARD_PORT, method: req.method, path: targetPath, headers, agent: internalAgent, }, (upRes) => { const contentType = String(upRes.headers["content-type"] || ""); const shouldRewrite = contentType.includes("text/html") || contentType.includes("application/xhtml"); if (!shouldRewrite) { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); // D1: handle mid-response backend socket errors on the non-rewrite // path (the rewrite path has its own upRes.on('error') below). upRes.on("error", () => { if (!res.headersSent) { try { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_error" })); } catch {} } else { try { res.destroy(); } catch {} } }); return; } const chunks = []; upRes.on("data", (chunk) => chunks.push(chunk)); upRes.on("end", () => { let body = Buffer.concat(chunks).toString("utf8"); // Tell the React router its basename. body = body.replace( /window\.__HERMES_BASE_PATH__\s*=\s*"[^"]*"/g, `window.__HERMES_BASE_PATH__="${HM_PREFIX}/app"`, ); // Prefix absolute asset URLs so they stay under /hm/app. const prefix = `${HM_PREFIX}/app`; body = body.replace( /\b(src|href)="\/(?!\/|http)([^"]*)"/g, (match, attr, rest) => { if ( ("/" + rest).startsWith(prefix + "/") || "/" + rest === prefix ) { return match; } return `${attr}="${prefix}/${rest}"`; }, ); const buf = Buffer.from(body, "utf8"); const outHeaders = { ...upRes.headers }; delete outHeaders["content-length"]; delete outHeaders["transfer-encoding"]; delete outHeaders["content-encoding"]; outHeaders["content-length"] = String(buf.length); res.writeHead(upRes.statusCode || 502, outHeaders); res.end(buf); }); upRes.on("error", () => { // D2: guard headersSent — in the rewrite path, res.writeHead may // already have fired (it fires in the 'end' handler). The old code // called writeHead unconditionally → ERR_HTTP_HEADERS_SENT. if (!res.headersSent) { try { res.writeHead(502); res.end(); } catch {} } else { try { res.destroy(); } catch {} } }); }, ); // D3: 30s timeout on the dashboard upstream request. upstream.setTimeout(30000, () => { if (!res.headersSent) { res.writeHead(504, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "upstream_timeout" })); } try { upstream.destroy(new Error("upstream_timeout")); } catch {} }); // D2: guard headersSent on the ClientRequest error handler. The old code // called writeHead unconditionally, which throws if the response callback // already fired and is streaming. upstream.on("error", (error) => { if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "proxy_error", message: error.message })); } else { try { res.destroy(); } catch {} } }); // Buffer body before forwarding — same chunked-encoding fix as proxyRequest. // Cap the body at 20 MB (mirrors proxyRequest) so a logged-in user (or anyone // if API_SERVER_KEY is unset) can't OOM the single Node router process by // POSTing an arbitrarily large body to /hm/app/api/*. const hasBody = req.method === "POST" || req.method === "PUT" || req.method === "PATCH"; if (hasBody) { const bodyChunks = []; let bodySize = 0; const bodyLimit = 20 * 1024 * 1024; req.on("data", (chunk) => { bodyChunks.push(chunk); bodySize += chunk.length; if (bodySize > bodyLimit) { req.destroy(); if (!res.headersSent) { res.writeHead(413, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "payload_too_large" })); } } }); req.on("end", () => { delete headers["transfer-encoding"]; headers["content-length"] = String(bodySize); upstream.end(Buffer.concat(bodyChunks)); }); req.on("error", (error) => { if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "proxy_error", message: error.message })); } }); } else { req.pipe(upstream); } } /* ── Status JSON + HuggingMes status page ─────────────────────────── */ function formatUptime(ms) { const total = Math.floor(ms / 1000); const days = Math.floor(total / 86400); const hours = Math.floor((total % 86400) / 3600); const minutes = Math.floor((total % 3600) / 60); if (days) return `${days}d ${hours}h ${minutes}m`; if (hours) return `${hours}h ${minutes}m`; return `${minutes}m`; } // Memoize statusPayload for ~1.5s. Every /health, /hm, /hm/status, /status // call previously re-probed all backends sequentially (up to ~2.4s). The // Cloudflare keepalive + HF probes hit these frequently, and under load the // serial awaits + synchronous file reads blocked the single-threaded event // loop. A short TTL promise collapses concurrent calls into one probe set. let _statusCache = null; let _statusCacheAt = 0; const STATUS_CACHE_TTL_MS = 1500; async function readJsonAsync(path, fallback = null) { try { const content = await fs.promises.readFile(path, "utf8"); return JSON.parse(content); } catch { return fallback; } } async function statusPayload() { const now = Date.now(); if (_statusCache && now - _statusCacheAt < STATUS_CACHE_TTL_MS) { return _statusCache; } // Parallel probes: 3-4 independent TCP/HTTP checks in one tick instead of // 3-4 sequential awaits (~2.4s → ~0.8s worst case). const hasTelegramWebhook = !!process.env.TELEGRAM_WEBHOOK_URL; const [gateway, dashboard, webui, telegramWebhook] = await Promise.all([ httpProbe(GATEWAY_PORT), httpProbe(DASHBOARD_PORT), httpProbe(WEBUI_PORT), hasTelegramWebhook ? httpProbe(TELEGRAM_WEBHOOK_PORT) : Promise.resolve(false), ]); const sync = await readJsonAsync( SYNC_STATUS_FILE, process.env.HF_TOKEN ? { status: "configured", message: "Backup enabled; waiting for first sync." } : { status: "disabled", message: "HF_TOKEN is not configured." }, ); const keepalive = await readJsonAsync(CLOUDFLARE_KEEPALIVE_STATUS_FILE, null); const payload = { ok: gateway && webui, uptime: formatUptime(Date.now() - startTime), startedAt: new Date(startTime).toISOString(), gateway, dashboard, webui, authConfigured: !!API_SERVER_KEY, primaryUi: PRIMARY_UI, ports: { public: PORT, gateway: GATEWAY_PORT, dashboard: DASHBOARD_PORT, webui: WEBUI_PORT, telegramWebhook: TELEGRAM_WEBHOOK_PORT, }, telegram: { configured: !!process.env.TELEGRAM_BOT_TOKEN, webhook: !!process.env.TELEGRAM_WEBHOOK_URL, webhookUrl: process.env.TELEGRAM_WEBHOOK_URL || "", webhookListening: telegramWebhook, proxy: process.env.CLOUDFLARE_PROXY_URL || "", }, model: process.env.MODEL_FOR_CONFIG || process.env.HERMES_MODEL || process.env.LLM_MODEL || "", provider: process.env.PROVIDER_FOR_CONFIG || process.env.HERMES_INFERENCE_PROVIDER || "auto", backup: sync, keepalive, // Stable dashboard session token for the Hermes desktop app. Persisted // by start.sh so it survives restarts — the user configures the desktop // app once and it stays connected across Space reboots. dashboardSessionToken: process.env.HERMES_DASHBOARD_SESSION_TOKEN || "", }; _statusCache = payload; _statusCacheAt = now; return payload; } function toneBadge(label, tone = "neutral") { return `${escapeHtml(label)}`; } function valueOrUnset(value, fallback = "Not set") { return value ? escapeHtml(value) : `${escapeHtml(fallback)}`; } function renderTile({ title, value, detail = "", tone = "neutral", meta = "" }) { return `
${escapeHtml(title)}
${value}
${detail ? `
${detail}
` : ""} ${meta ? `
${meta}
` : ""}
`; } function renderStatusPage(data) { const syncStatus = String(data.backup?.status || "unknown"); const syncTone = ["success", "restored", "synced", "configured"].includes(syncStatus) ? "ok" : syncStatus === "disabled" ? "warn" : "neutral"; const telegramTone = data.telegram.configured ? data.telegram.webhookListening || !data.telegram.webhook ? "ok" : "warn" : "warn"; const keepaliveConfigured = data.keepalive?.configured === true; const keepaliveStatus = String( data.keepalive?.status || (process.env.CLOUDFLARE_WORKERS_TOKEN ? "pending" : "not configured"), ); const keepAliveTone = keepaliveConfigured ? "ok" : process.env.CLOUDFLARE_WORKERS_TOKEN ? "warn" : "neutral"; const telegramDetail = data.telegram.configured ? `${data.telegram.webhook ? "Webhook" : "Polling"}${data.telegram.proxy ? " via CF proxy" : ""}` : "Not configured"; const backupDetail = data.backup?.message ? escapeHtml(data.backup.message) : "No status yet"; // Extra one-line warning row for known-loud failure modes (currently: // ephemeral .env on a Space). hermes-sync.py emits this via warning.message. const backupWarning = data.backup?.warning?.message ? `
${escapeHtml(data.backup.warning.message)}
` : ""; const keepAliveDetail = keepaliveConfigured ? `Pinging ${escapeHtml(data.keepalive.targetUrl || "/health")}` : keepaliveStatus === "error" && data.keepalive?.message ? escapeHtml(data.keepalive.message) : process.env.CLOUDFLARE_WORKERS_TOKEN ? "Worker pending or failed" : "Not configured"; const tiles = [ renderTile({ title: "WebUI", value: toneBadge(data.webui ? "Online" : "Offline", data.webui ? "ok" : "off"), detail: data.webui ? `Port ${data.ports.webui}` : "Unreachable", tone: data.webui ? "ok" : "off", }), renderTile({ title: "Gateway", value: toneBadge(data.gateway ? "Online" : "Offline", data.gateway ? "ok" : "off"), detail: data.gateway ? `API on port ${data.ports.gateway}` : "Unreachable", tone: data.gateway ? "ok" : "off", meta: data.authConfigured ? "Protected" : "Unprotected", }), renderTile({ title: "Model", value: `${valueOrUnset(data.model)}`, detail: `Provider: ${valueOrUnset(data.provider || "auto")}`, tone: data.model ? "ok" : "warn", }), renderTile({ title: "Desktop App", value: data.dashboardSessionToken ? toneBadge("Ready", "ok") : toneBadge("No token", "warn"), detail: data.dashboardSessionToken ? `Setup guide · token: ${escapeHtml(data.dashboardSessionToken.slice(0, 8))}…` : "HERMES_DASHBOARD_SESSION_TOKEN not set", tone: data.dashboardSessionToken ? "ok" : "warn", }), renderTile({ title: "Runtime", value: escapeHtml(data.uptime), detail: `Port ${data.ports.public}`, tone: "neutral", }), renderTile({ title: "Telegram", value: toneBadge(data.telegram.configured ? "Configured" : "Disabled", telegramTone), detail: telegramDetail, tone: telegramTone, }), renderTile({ title: "Backup", value: toneBadge(syncStatus.toUpperCase(), data.backup?.warning ? "warn" : syncTone), detail: backupDetail + backupWarning, tone: data.backup?.warning ? "warn" : syncTone, meta: data.backup?.timestamp ? `` : "", }), renderTile({ title: "Keep Awake", value: toneBadge( keepaliveConfigured ? "CF Cron" : keepaliveStatus.toUpperCase(), keepAliveTone, ), detail: keepAliveDetail, tone: keepAliveTone, }), ].join(""); return ` HuggingMes + Hermes WebUI

HuggingMes + Hermes WebUI

Self-hosted Hermes Agent on HF Spaces
${tiles}
`; } /* ── Server ───────────────────────────────────────────────────────── */ const server = http.createServer(async (req, res) => { const parsed = new URL(req.url, "http://localhost"); const path = parsed.pathname; // 1. /hm/login — HuggingMes admin login (cookie-based, gates /hm/*). // hermes-webui handles its own /login at the catch-all below. if (path === LOGIN_PATH) { await handleLogin(req, res, parsed); return; } // 2. /health — unauthenticated; HF Spaces probes + Cloudflare keepalive. if (path === "/health") { const data = await statusPayload(); res.writeHead(data.ok ? 200 : 503, { "content-type": "application/json" }); res.end( JSON.stringify({ ok: data.ok, gateway: data.gateway, webui: data.webui, uptime: data.uptime, }), ); return; } // 3. /status — admin diagnostics. Gate behind requireAuth to avoid leaking // internal ports, telegram.webhookUrl, model/provider, authConfigured, // and backup/keepalive state to unauthenticated callers. /health above // stays public (it only returns ok/gateway/webui/uptime). if (path === "/status" || path === "/api/status") { if (!requireAuth(req, res)) return; const data = await statusPayload(); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(data, null, 2)); return; } // 4. /telegram — webhook endpoint; no router auth (Telegram can't do our // cookie), but only forward if Telegram is actually configured. The // gateway's webhook handler validates X-Telegram-Bot-Api-Secret-Token. if (path === "/telegram" || path.startsWith("/telegram/")) { if (!process.env.TELEGRAM_BOT_TOKEN) { res.writeHead(404, { "content-type": "application/json" }); res.end(JSON.stringify({ error: "telegram_not_configured" })); return; } proxyRequest(req, res, TELEGRAM_WEBHOOK_PORT); return; } // 5. /v1/* — Hermes gateway OpenAI-compatible API. if (path === "/v1" || path.startsWith("/v1/")) { if (!isAuthorized(req)) { if (wantsHtml(req)) { redirect(res, loginUrl(`${path}${parsed.search}`)); return; } res.writeHead(401, { "content-type": "application/json", "cache-control": "no-store", }); res.end( JSON.stringify({ error: "unauthorized", message: "Use Authorization: Bearer .", }), ); return; } const upstreamHeaders = getBearerToken(req) || !API_SERVER_KEY ? {} : { authorization: `Bearer ${API_SERVER_KEY}` }; proxyRequest(req, res, GATEWAY_PORT, (p) => p, upstreamHeaders); return; } // 6. /hm — HuggingMes status page. if (path === HM_PREFIX || path === `${HM_PREFIX}/`) { if (!requireAuth(req, res)) return; const data = await statusPayload(); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(renderStatusPage(data)); return; } // /hmd/* — Off-Space dashboard passthrough. // // Forwards verbatim to the internal Hermes dashboard on DASHBOARD_PORT, // including its /api/* endpoints, /assets/*, root HTML (which carries the // ephemeral session token), and WebSocket upgrades. Workspace clients // (e.g. hermes-workspace) point HERMES_DASHBOARD_URL at // https:///hmd // and the workspace's own scrape-the-token-from-root-HTML logic just // works because /hmd/ returns the unmodified dashboard index. // // SECURITY: this prefix has no router-level auth on purpose — the // dashboard's own session token gates writes. If you need an extra layer, // wrap your Space behind a Cloudflare Access policy or remove this // handler. if (path === HMD_PREFIX || path.startsWith(`${HMD_PREFIX}/`)) { proxyRequest(req, res, DASHBOARD_PORT, (p) => p.replace(HMD_PREFIX, "") || "/"); return; } // /hm/app/* -> Hermes dashboard (SPA with HTML rewriting for base path) if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) { if (!requireAuth(req, res)) return; proxyDashboard(req, res); return; } // /hm/status -> JSON if (path === `${HM_PREFIX}/status`) { if (!requireAuth(req, res)) return; const data = await statusPayload(); res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(data, null, 2)); return; } // /hm/desktop-app-setup — copy-pasteable desktop app connection info. // Shows the stable session token + remote gateway URL so the user can // configure the Hermes desktop app once and have it survive restarts. if (path === `${HM_PREFIX}/desktop-app-setup`) { if (!requireAuth(req, res)) return; const token = process.env.HERMES_DASHBOARD_SESSION_TOKEN || ""; const host = req.headers["x-forwarded-host"] || req.headers.host || ""; const baseUrl = host ? `https://${host}` : ""; const remoteUrl = `${baseUrl}${HMD_PREFIX}`; if (wantsHtml(req)) { res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(`Desktop App Setup

Hermes Desktop App — Remote Setup

Configure once. These values persist across Space restarts (the session token is saved to the backed-up state volume).

Remote Gateway URL
${escapeHtml(remoteUrl)}
Session Token
${escapeHtml(token)}
Steps
1. Open the Hermes desktop app
2. Settings → Gateway → Remote gateway
3. URL: paste the Remote Gateway URL above
4. Session token: paste the token above
5. Connect — it should stay connected across restarts

In the desktop app: chat, model picker, and settings work remotely. File browser and terminal panel show your local PC (upstream desktop app limitation). For remote files/terminal, use the WebUI at /.

`); } else { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ remoteGatewayUrl: remoteUrl, sessionToken: token, note: "Configure once in the desktop app: Settings → Gateway → Remote gateway. Persists across restarts.", }, null, 2)); } return; } // /hm/logs — view service logs without needing HF Pro SSH. if (path === `${HM_PREFIX}/logs` || path.startsWith(`${HM_PREFIX}/logs/`)) { if (!requireAuth(req, res)) return; const logDir = `${process.env.HERMES_HOME || "/opt/data"}/logs`; const logFiles = ["dashboard.log", "gateway.log", "webui.log"]; if (path.startsWith(`${HM_PREFIX}/logs/`)) { const name = path.slice(`${HM_PREFIX}/logs/`.length); if (!logFiles.includes(name)) { res.writeHead(404, { "content-type": "text/plain" }); res.end("Not found"); return; } try { // Validate + clamp the tail param. Previously NaN/negative/huge // values were passed straight to slice(-tail), which behaved oddly. let tail = Number(parsed.searchParams.get("tail") || 200); if (!Number.isFinite(tail) || tail < 0) tail = 200; if (tail > 10000) tail = 10000; const filePath = `${logDir}/${name}`; const stat = fs.statSync(filePath); // Cap file size before reading. A multi-hundred-MB log read // synchronously would block the event loop for seconds and spike // memory. Reject files over 50 MB with a 413 instead. if (stat.size > 50 * 1024 * 1024) { res.writeHead(413, { "content-type": "text/plain" }); res.end(`Log file ${name} is ${(stat.size / 1024 / 1024).toFixed(1)} MB — too large to serve in-browser. SSH in or rotate the log first.`); return; } const content = await fs.promises.readFile(filePath, "utf8"); const lines = content.split("\n"); const sliced = lines.slice(-tail); res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); res.end(sliced.join("\n")); } catch { res.writeHead(404, { "content-type": "text/plain" }); res.end(`Log file ${name} not found`); } return; } const links = logFiles.map((f) => { const size = (() => { try { return fs.statSync(`${logDir}/${f}`).size; } catch { return 0; } })(); return `
  • ${escapeHtml(f)} (${(size / 1024).toFixed(1)} KB)
  • `; }).join(""); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(`HuggingMes Logs

    Service Logs

    Append ?tail=N to limit lines (default 200, max 10000).

      ${links}
    `); return; } // /hm/debug/model-options — debug proxy: fetch /api/model/options from // the dashboard directly and return the raw response so we can see the // actual error body without needing SSH/Pro. if (path === `${HM_PREFIX}/debug/model-options`) { if (!requireAuth(req, res)) return; const localHost = `${GATEWAY_HOST}:${DASHBOARD_PORT}`; const localOrigin = `http://${localHost}`; // Step 1: fetch dashboard root to extract session token const rootReq = http.request( { hostname: GATEWAY_HOST, port: DASHBOARD_PORT, method: "GET", path: "/", headers: { host: localHost, origin: localOrigin }, agent: internalAgent }, (rootRes) => { const chunks = []; rootRes.on("data", (c) => chunks.push(c)); rootRes.on("end", () => { const html = Buffer.concat(chunks).toString("utf8"); const m = html.match(/__HERMES_SESSION_TOKEN__\s*[=:]\s*["']([A-Za-z0-9_\-]+)["']/) || html.match(/session[_-]?token\s*[=:]\s*["']([A-Za-z0-9_\-]+)["']/i); const token = m ? m[1] : ""; if (!token) { res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); res.end(`Could not extract session token from dashboard HTML.\n\nHTML preview (first 500 chars):\n${html.slice(0, 500)}`); return; } // Step 2: hit /api/model/options with the token const apiReq = http.request( { hostname: GATEWAY_HOST, port: DASHBOARD_PORT, method: "GET", path: "/api/model/options", headers: { host: localHost, origin: localOrigin, "x-hermes-session-token": token }, agent: internalAgent }, (apiRes) => { const bodyChunks = []; apiRes.on("data", (c) => bodyChunks.push(c)); apiRes.on("end", () => { const body = Buffer.concat(bodyChunks).toString("utf8"); res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); res.end(`Token: ${token.slice(0, 8)}...\nStatus: ${apiRes.statusCode}\nHeaders: ${JSON.stringify(apiRes.headers, null, 2)}\n\n${body}`); }); apiRes.on("error", (e) => { res.writeHead(502, { "content-type": "text/plain" }); res.end(`API probe error: ${e.message}`); }); }, ); apiReq.on("error", (e) => { res.writeHead(502, { "content-type": "text/plain" }); res.end(`API connection error: ${e.message}`); }); apiReq.end(); }); rootRes.on("error", (e) => { res.writeHead(502, { "content-type": "text/plain" }); res.end(`Dashboard root error: ${e.message}`); }); }, ); rootReq.on("error", (e) => { res.writeHead(502, { "content-type": "text/plain" }); res.end(`Dashboard connection error: ${e.message}`); }); rootReq.end(); return; } // /hm/debug/model-options-trace — runs Python directly to call // build_models_payload() with full traceback output. if (path === `${HM_PREFIX}/debug/model-options-trace`) { if (!requireAuth(req, res)) return; const { execFile } = require("child_process"); const pyCode = ` import os, sys, traceback os.environ.setdefault("HERMES_HOME", "/opt/data") sys.path.insert(0, "/opt/hermes") sys.path.insert(0, "/opt/hermes/.venv/lib/python3.12/site-packages") try: from hermes_cli.inventory import build_models_payload, load_picker_context ctx = load_picker_context() print("=== load_picker_context OK ===") print(" current_model:", repr(ctx.current_model)) print(" current_provider:", repr(ctx.current_provider)) print(" current_base_url:", repr(ctx.current_base_url)) print(" user_providers:", type(ctx.user_providers).__name__, list(ctx.user_providers.keys()) if isinstance(ctx.user_providers, dict) else "") print(" custom_providers:", type(ctx.custom_providers).__name__, list(ctx.custom_providers.keys()) if isinstance(ctx.custom_providers, dict) else "") except Exception: print("=== load_picker_context FAILED ===") traceback.print_exc() sys.exit(0) try: result = build_models_payload(ctx, max_models=50, include_unconfigured=True, picker_hints=True, canonical_order=True, pricing=True, capabilities=True) print("=== build_models_payload OK ===") print(" providers count:", len(result.get("providers", []))) print(" model:", repr(result.get("model"))) print(" provider:", repr(result.get("provider"))) except Exception: print("=== build_models_payload FAILED ===") traceback.print_exc() `; execFile("/opt/hermes/.venv/bin/python", ["-c", pyCode], { timeout: 30000 }, (err, stdout, stderr) => { res.writeHead(200, { "content-type": "text/plain; charset=utf-8" }); res.end(`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}\n--- exit ---\n${err ? err.message : "0"}`); }); return; } // Legacy /dashboard -> /hm if (path === "/dashboard" || path === "/dashboard/") { redirect(res, `${HM_PREFIX}${parsed.search}`); return; } // Root-path dashboard routes (config, env, providers, etc.) that users // type or bookmark without the /hm/app prefix. Redirect them there. const dashboardRootRoutes = new Set([ "/config", "/env", "/models", "/providers", "/profiles", "/sessions", "/skills", "/cron", "/analytics", "/logs", "/plugins", "/chat", "/docs", ]); if (dashboardRootRoutes.has(path) || [...dashboardRootRoutes].some((r) => path.startsWith(r + "/"))) { redirect(res, `${HM_PREFIX}/app${path}${parsed.search}`); return; } // 6b. Root-path requests whose Referer came from /hm/app/* must go to // the dashboard, not WebUI. This covers: // - Absolute assets (/assets/*, /ds-assets/*, /dashboard-plugins/*) // - API calls (/api/*) when dashboard code uses absolute paths // - Favicon (/favicon.ico) // - WebSocket upgrades from dashboard pages // - File downloads (any extensioned path referenced by dashboard) // Both the Hermes dashboard AND hermes-webui use /api/* internally, // so the Referer is the only reliable way to disambiguate. const refererPath = (() => { const ref = String(req.headers.referer || ""); if (!ref) return ""; try { return new URL(ref).pathname; } catch { return ""; } })(); const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`); // NOTE: Referer is client-controlled, so a caller who sets Referer: /hm/app // can route requests that would otherwise go to WebUI (e.g. /api/*) to the // dashboard. This is functional routing, not a privilege boundary — the // block below calls requireAuth() before proxying to the dashboard, so a // spoofed Referer doesn't grant any access the caller didn't already have. if (refererIsDashboard) { // Anything with a Referer from the dashboard goes to the dashboard, // *except* requests that explicitly start with /webui (escape hatch). if (!path.startsWith("/webui")) { if (!requireAuth(req, res)) return; // Assets must NOT get the SPA fallback; pass them through as-is. const parsed2 = new URL(req.url, "http://localhost"); const looksLikeAsset = path.startsWith("/assets/") || path.startsWith("/ds-assets/") || path.startsWith("/dashboard-plugins/") || path.startsWith("/api/") || path === "/favicon.ico" || /\.[a-z0-9]{1,6}$/i.test(path); if (looksLikeAsset) { proxyRequest(req, res, DASHBOARD_PORT); } else { // Unlikely: a dashboard-referrer request for a non-asset, non-/hm // path. Treat as a dashboard sub-route. proxyDashboard(req, res); } return; } } // 6c. /api/* routes — these are WebUI API calls when Referer isn't the // dashboard. Fall through to the catch-all below. // // Exception: hermes-workspace probes for the *legacy* enhanced-fork chat // endpoint at POST /api/sessions//chat/stream. Without this rule the // request falls through to WebUI's catch-all, which doesn't 404 it // cleanly, so the workspace's detector sets `enhancedChat=true`, sends // chat there at runtime, and the UI surfaces a generic "Authentication // error". Returning an explicit 404 here makes the workspace fall back // to the OpenAI-compatible /v1/chat/completions path on the gateway — // which is the only chat surface this Space actually exposes. // // Anything the dashboard or WebUI legitimately need under /api/sessions/ // already has a more specific match above (referer check / /hmd // passthrough), so this only fires for cross-origin probes. if ( /^\/api\/sessions\/[^/]+\/chat\/stream\/?$/.test(path) && !refererIsDashboard ) { res.writeHead(404, { "content-type": "application/json", "cache-control": "no-store", }); res.end( JSON.stringify({ error: "not_found", message: "Legacy enhanced-fork chat stream is not exposed by this Space. Use /v1/chat/completions.", }), ); return; } // 7. Anything else -> Hermes WebUI (primary UI) OR HuggingMes status page. // WebUI handles its own auth internally via HERMES_WEBUI_PASSWORD. if (PRIMARY_UI === "dashboard" && path === "/") { if (!requireAuth(req, res)) return; const data = await statusPayload(); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(renderStatusPage(data)); return; } // Catch-all -> WebUI. Don't gate at the router level: WebUI has its own // password login. GATEWAY_TOKEN *is* the WebUI password (start.sh sets // HERMES_WEBUI_PASSWORD=$GATEWAY_TOKEN). proxyRequest(req, res, WEBUI_PORT); }); server.listen(PORT, "0.0.0.0", () => { console.log(`HuggingMes + Hermes WebUI router listening on 0.0.0.0:${PORT}`); }); // D4: last-resort guards so one bad request/response can't crash the // router and take down every fronted service. An unhandled stream error // (e.g. a backend socket reset on a response we forgot to attach an // 'error' listener to) would otherwise throw and terminate the process. process.on("uncaughtException", (err) => { console.error("uncaughtException in router (continuing):", err && err.stack ? err.stack : err); }); process.on("unhandledRejection", (err) => { console.error("unhandledRejection in router (continuing):", err); }); server.on("error", (err) => { console.error("router server error:", err && err.stack ? err.stack : err); }); /* ── WebSocket upgrade handling ───────────────────────────────────── * * Both the Hermes dashboard and hermes-webui can open WebSocket * connections for live updates. Route the upgrade to the correct * upstream based on path prefix + referer, same as HTTP requests. * * SECURITY: the HTTP handler enforces isAuthorized() on /v1/* and * /hm/app/*. The upgrade handler used to enforce NONE of that, so a * client could open a WS to /v1/* without a bearer or /hm/app/* without * the session cookie — bypassing the entire auth model for WS. We now * mirror the HTTP auth on the WS upgrade path. /hmd* stays open by * design (off-Space desktop workspace; the dashboard's own session * token gates writes), but we validate Origin to prevent CSWSH. */ server.on("upgrade", (req, clientSocket, head) => { const parsed = new URL(req.url, "http://localhost"); const path = parsed.pathname; // Auth gate mirroring the HTTP handler. const needsAuth = path === "/v1" || path.startsWith("/v1/") || path === HM_PREFIX || path.startsWith(`${HM_PREFIX}/`) || path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`); if (needsAuth && !isAuthorized(req)) { try { clientSocket.end("HTTP/1.1 401 Unauthorized\r\n\r\n"); } catch { try { clientSocket.destroy(); } catch {} } return; } // Origin validation for ALL WS upgrades (CSWSH defense). allowedWsOrigin // returns true for empty Origin (non-browser clients like the desktop app) // and for hosts matching the Space / localhost / explicit allowlist. if (!allowedWsOrigin(req)) { try { clientSocket.end("HTTP/1.1 403 Forbidden\r\n\r\n"); } catch { try { clientSocket.destroy(); } catch {} } return; } let targetPort = WEBUI_PORT; let targetPath = req.url; const refererPath = (() => { const ref = String(req.headers.referer || ""); if (!ref) return ""; try { return new URL(ref).pathname; } catch { return ""; } })(); const refererIsDashboard = refererPath.startsWith(`${HM_PREFIX}/app`); // Whether to rewrite Host/Origin to the local backend so it accepts the // handshake. /v1 and /hm/app backends check against their own bind host; // /hmd passthrough forwards the real Origin and lets the dashboard decide. let rewriteLocalOrigin = true; if (path === "/v1" || path.startsWith("/v1/")) { targetPort = GATEWAY_PORT; } else if (path === HMD_PREFIX || path.startsWith(`${HMD_PREFIX}/`)) { // Off-Space dashboard passthrough (mirrors the HTTP /hmd handler). targetPort = DASHBOARD_PORT; targetPath = path.replace(HMD_PREFIX, "") || "/"; if (parsed.search) targetPath += parsed.search; rewriteLocalOrigin = false; // let the dashboard's own origin check run } else if (path === `${HM_PREFIX}/app` || path.startsWith(`${HM_PREFIX}/app/`)) { targetPort = DASHBOARD_PORT; targetPath = path.replace(`${HM_PREFIX}/app`, "") || "/"; if (parsed.search) targetPath += parsed.search; } else if (refererIsDashboard && !path.startsWith("/webui")) { targetPort = DASHBOARD_PORT; } else if (path.startsWith("/webui/") || path === "/webui") { targetPort = WEBUI_PORT; targetPath = path.replace(/^\/webui/, "") || "/"; if (parsed.search) targetPath += parsed.search; } const upstream = net.createConnection(targetPort, GATEWAY_HOST, () => { // Rewrite Host to the local backend so the dashboard/gateway accept the // WebSocket origin. Desktop app → HF proxy sends Host: .hf.space // but the dashboard checks against its own bind address (127.0.0.1:PORT). const localHost = `${GATEWAY_HOST}:${targetPort}`; const headerLines = [ `${req.method} ${targetPath} HTTP/1.1`, ]; for (const [name, value] of Object.entries(req.headers)) { const lower = name.toLowerCase(); if (lower === "host") { headerLines.push(`Host: ${localHost}`); continue; } if (lower === "origin") { if (rewriteLocalOrigin) { // /v1 + /hm/app: backend requires the local origin to accept the // handshake. We already validated the incoming Origin above. headerLines.push(`Origin: http://${localHost}`); } else { // /hmd passthrough: forward the real Origin so the dashboard's own // origin guard runs (it accepts the off-Space workspace's origin). headerLines.push(`Origin: ${value}`); } continue; } if (Array.isArray(value)) { for (const v of value) headerLines.push(`${name}: ${v}`); } else { headerLines.push(`${name}: ${value}`); } } headerLines.push("", ""); upstream.write(headerLines.join("\r\n")); if (head && head.length) upstream.write(head); upstream.pipe(clientSocket); clientSocket.pipe(upstream); }); upstream.on("error", () => { try { clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n"); } catch {} }); clientSocket.on("error", () => { try { upstream.destroy(); } catch {} }); });