#!/usr/bin/env node /** * Hermes Control Plane v2 — local web UI. * * Exposes most Hermes CLI features (auth pools, routing/fallback, tools, * skills, MCP, cron, sessions, theme, system) behind buttons. No messaging, * no chat — chat lives elsewhere (e.g. OpenWebUI). * * Tiny Node http server, zero dependencies. */ "use strict" const http = require("http") const https = require("https") const fs = require("fs") const path = require("path") const { spawn } = require("child_process") const os = require("os") const crypto = require("crypto") const { createPool, runMigrations } = require("./lib/db.cjs") const { required } = require("./lib/config.cjs") const { adminCredentialsMatch, createSessionToken, hashSecret, parseCookies, serializeAdminCookie, clearAdminCookie } = require("./lib/security.cjs") const { createAdminSession, validateAdminSession, revokeAdminSession } = require("./lib/admin-store.cjs") const { readJsonBody, sendJson } = require("./lib/http.cjs") const { createApiUser, listApiUsers, updateApiUser, rotateApiUserKey, revokeApiUser, reactivateApiUser, deleteApiUser, } = require("./lib/api-users-store.cjs") const { streamJsonlLogs, cleanupExpiredMessageLogs } = require("./lib/audit-store.cjs") const PORT = Number(process.env.HERMES_SETUP_UI_PORT || 7843) const HOST = process.env.HERMES_SETUP_UI_HOST || "127.0.0.1" const defaultHermesHome = process.platform === "win32" ? path.join(os.homedir(), "AppData", "Local", "hermes") : path.join(os.homedir(), ".hermes") const defaultHermesExe = process.platform === "win32" ? path.join(defaultHermesHome, "hermes-agent", "venv", "Scripts", "hermes.exe") : path.join(defaultHermesHome, "hermes-agent", "venv", "bin", "hermes") const HERMES_HOME = process.env.HERMES_HOME || defaultHermesHome const HERMES_EXE = process.env.HERMES_EXE || defaultHermesExe // Python interpreter that sits beside the hermes entrypoint in the same venv. // Used to drive hermes's own config helpers (save_env_value / save_config) when // registering a URL-mode MCP server, so we never trigger the interactive + // network-probing `hermes mcp add` flow (which hangs/504s on auth challenges). const defaultHermesPython = path.join( path.dirname(HERMES_EXE), process.platform === "win32" ? "python.exe" : "python" ) const HERMES_PYTHON = process.env.HERMES_PYTHON || defaultHermesPython const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), ".codex") const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude") const GEMINI_CONFIG_DIR = process.env.GEMINI_CONFIG_DIR || path.join(os.homedir(), ".gemini") const ENV_FILE = path.join(HERMES_HOME, ".env") const CONFIG_FILE = path.join(HERMES_HOME, "config.yaml") const STATIC_DIR = __dirname const CODEX_DEVICE_AUTH_BASE = (process.env.HERMES_CODEX_DEVICE_AUTH_BASE || "https://auth.openai.com").replace(/\/+$/, "") const CODEX_DEVICE_VERIFY_URL = process.env.HERMES_CODEX_DEVICE_VERIFY_URL || "https://auth.openai.com/codex/device" const CODEX_OAUTH_CLIENT_ID = process.env.HERMES_CODEX_OAUTH_CLIENT_ID || "app_EMoamEEZ73f0CkXaXp7hrann" const CODEX_OAUTH_TOKEN_URL = process.env.HERMES_CODEX_OAUTH_TOKEN_URL || "https://auth.openai.com/oauth/token" const CODEX_BASE_URL = (process.env.HERMES_CODEX_BASE_URL || "https://chatgpt.com/backend-api/codex").replace(/\/+$/, "") const DATABASE_URL = process.env.DATABASE_URL const ADMIN_USERNAME = process.env.HERMES_ADMIN_USERNAME const ADMIN_PASSWORD = process.env.HERMES_ADMIN_PASSWORD const SESSION_TTL_SECONDS = Number(process.env.HERMES_ADMIN_SESSION_TTL_HOURS || 12) * 3600 const ADMIN_COOKIE_SECURE = /^(1|true|yes|on)$/i.test(String(process.env.HERMES_ADMIN_COOKIE_SECURE || "")) // Module-level pool — null when DATABASE_URL is not set (auth disabled) let pool = null // ─── Process tracking ───────────────────────────────────────────────────── const runningProcs = new Map() // key: provider, val: state function runHermes(args, timeoutMs = 30000) { return new Promise((resolve) => { const proc = spawn(HERMES_EXE, args, { env: { ...process.env, NO_COLOR: "1", HERMES_NO_TUI: "1", PYTHONIOENCODING: "utf-8" }, windowsHide: true }) let stdout = "" let stderr = "" let settled = false function settle(result) { if (settled) return settled = true resolve(result) } const timer = setTimeout(() => { try { proc.kill("SIGKILL") } catch {} settle({ code: -1, stdout, stderr: stderr + "\ntimeout" }) }, timeoutMs) proc.stdout.on("data", (d) => { stdout += d.toString("utf-8") }) proc.stderr.on("data", (d) => { stderr += d.toString("utf-8") }) proc.on("close", (code) => { clearTimeout(timer); settle({ code, stdout, stderr }) }) proc.on("error", (err) => { clearTimeout(timer); settle({ code: -1, stdout, stderr: stderr + "\n" + err.message }) }) }) } function spawnHermesTracked(provider, args) { const proc = spawn(HERMES_EXE, args, { env: { ...process.env, NO_COLOR: "1", HERMES_NO_TUI: "1", PYTHONIOENCODING: "utf-8", BROWSER: process.env.BROWSER || "echo" }, windowsHide: true }) const state = { proc, lines: [], done: false, exitCode: null, startedAt: Date.now(), aborted: false } runningProcs.set(provider, state) const append = (chunk) => { state.lines.push(chunk.toString("utf-8")) if (state.lines.length > 200) state.lines.shift() } proc.stdout.on("data", append) proc.stderr.on("data", append) proc.on("close", (code) => { state.done = true; state.exitCode = code }) proc.on("error", (err) => { state.lines.push(`\n[error] ${err.message}\n`) state.done = true; state.exitCode = -1 }) return state } function appendTrackedLine(state, line) { state.lines.push(line) if (state.lines.length > 200) state.lines.shift() } async function fetchJson(url, options = {}) { const response = await fetch(url, { ...options, headers: { "Accept": "application/json", ...(options.body ? { "Content-Type": "application/json" } : {}), ...(options.headers || {}), }, }) const text = await response.text() let body = null try { body = text ? JSON.parse(text) : null } catch { body = text } if (!response.ok) { const detail = body && typeof body === "object" ? (body.error_description || body.error || JSON.stringify(body)) : String(body || response.statusText) const err = new Error(`${response.status} ${detail}`) err.status = response.status throw err } return body || {} } function loadHermesAuthStore() { try { const parsed = JSON.parse(fs.readFileSync(path.join(HERMES_HOME, "auth.json"), "utf-8")) if (parsed && typeof parsed === "object") return parsed } catch {} return { version: 1, providers: {}, credential_pool: {} } } function saveHermesAuthStore(store) { fs.mkdirSync(HERMES_HOME, { recursive: true }) store.version = store.version || 1 store.updated_at = new Date().toISOString() const target = path.join(HERMES_HOME, "auth.json") const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(6).toString("hex")}` fs.writeFileSync(tmp, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 }) fs.renameSync(tmp, target) try { fs.chmodSync(target, 0o600) } catch {} } function labelFromToken(token, fallback) { const identity = identityFromToken(token) return identity?.email || identity?.name || identity?.id || fallback } function saveCodexCredential(tokens) { const accessToken = String(tokens.access_token || "") if (!accessToken) throw new Error("token exchange did not return access_token") const store = loadHermesAuthStore() if (!store.credential_pool || typeof store.credential_pool !== "object") store.credential_pool = {} if (!Array.isArray(store.credential_pool["openai-codex"])) store.credential_pool["openai-codex"] = [] const entries = store.credential_pool["openai-codex"] const label = labelFromToken(accessToken, `dashboard device code ${entries.length + 1}`) entries.push({ id: crypto.randomBytes(3).toString("hex"), label, auth_type: "oauth", priority: 0, source: "manual:dashboard_device_code", access_token: accessToken, refresh_token: tokens.refresh_token || null, base_url: CODEX_BASE_URL, last_status: null, last_status_at: null, last_error_code: null, last_error_reason: null, last_error_message: null, last_error_reset_at: null, request_count: 0, }) saveHermesAuthStore(store) return label } function startCodexDeviceOauth() { const provider = "openai-codex" const state = { proc: null, lines: [], done: false, exitCode: null, startedAt: Date.now(), aborted: false, } runningProcs.set(provider, state) ;(async () => { try { const deviceData = await fetchJson(`${CODEX_DEVICE_AUTH_BASE}/api/accounts/deviceauth/usercode`, { method: "POST", body: JSON.stringify({ client_id: CODEX_OAUTH_CLIENT_ID }), }) const userCode = String(deviceData.user_code || "") const deviceAuthId = String(deviceData.device_auth_id || "") const intervalSeconds = Math.max(3, Number(deviceData.interval || 5)) if (!userCode || !deviceAuthId) throw new Error("device-code response missing user_code or device_auth_id") appendTrackedLine(state, `Open this URL to authorize Codex OAuth:\n${CODEX_DEVICE_VERIFY_URL}\n\nCode: ${userCode}\n`) const deadline = Date.now() + (15 * 60 * 1000) let authorizationCode = "" let codeVerifier = "" while (!state.aborted && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, intervalSeconds * 1000)) try { const poll = await fetchJson(`${CODEX_DEVICE_AUTH_BASE}/api/accounts/deviceauth/token`, { method: "POST", body: JSON.stringify({ device_auth_id: deviceAuthId, user_code: userCode }), }) authorizationCode = String(poll.authorization_code || "") codeVerifier = String(poll.code_verifier || "") break } catch (err) { if (err.status === 403 || err.status === 404) continue throw err } } if (state.aborted) { state.done = true state.exitCode = -1 appendTrackedLine(state, "\nOAuth aborted.\n") return } if (!authorizationCode || !codeVerifier) throw new Error("device code expired before approval") const tokenResponse = await fetchJson(CODEX_OAUTH_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: authorizationCode, redirect_uri: `${CODEX_DEVICE_AUTH_BASE}/deviceauth/callback`, client_id: CODEX_OAUTH_CLIENT_ID, code_verifier: codeVerifier, }).toString(), }) const label = saveCodexCredential(tokenResponse) appendTrackedLine(state, `\nAdded openai-codex OAuth credential: "${label}"\n`) state.done = true state.exitCode = 0 } catch (err) { appendTrackedLine(state, `\n[error] ${err.message}\n`) state.done = true state.exitCode = -1 } })() return state } // ─── HTTP helpers ───────────────────────────────────────────────────────── function send(res, status, body, type = "application/json; charset=utf-8") { res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" }) res.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body)) } function readBody(req) { return new Promise((resolve) => { const chunks = [] req.on("data", (c) => chunks.push(c)) req.on("end", () => { const raw = Buffer.concat(chunks).toString("utf-8") try { resolve(raw ? JSON.parse(raw) : {}) } catch { resolve({}) } }) }) } // ─── YAML editor (minimal, targeted) ────────────────────────────────────── // Only manipulates known top-level sections (fallback_providers, model.provider, // model.default, credential_pool_strategies). Avoids pulling in a yaml lib. function readConfig() { return fs.readFileSync(CONFIG_FILE, "utf-8") } function writeConfig(text) { const bak = `${CONFIG_FILE}.bak.${Date.now()}` fs.copyFileSync(CONFIG_FILE, bak) fs.writeFileSync(CONFIG_FILE, text, "utf-8") } function replaceFallbackBlock(text, entries) { // entries: [{provider, model, base_url?, key_env?}] const lines = text.split(/\r?\n/) const startIdx = lines.findIndex((l) => /^fallback_providers:\s*$/.test(l)) if (startIdx === -1) { // Append at end const block = serializeFallback(entries) return text.replace(/\s*$/, "\n\n" + block) } // Find end of block: next non-indented non-comment non-blank line (top-level key) let endIdx = lines.length for (let i = startIdx + 1; i < lines.length; i++) { const l = lines[i] if (l.length === 0) continue if (/^[#\s]/.test(l)) continue endIdx = i break } const before = lines.slice(0, startIdx).join("\n") const after = lines.slice(endIdx).join("\n") const block = serializeFallback(entries) return (before ? before + "\n" : "") + block + (after ? "\n" + after : "") } function serializeFallback(entries) { if (!entries.length) return "fallback_providers: []" const lines = ["fallback_providers:"] for (const e of entries) { lines.push(` - provider: ${e.provider}`) lines.push(` model: ${e.model}`) if (e.base_url) lines.push(` base_url: ${e.base_url}`) if (e.key_env) lines.push(` key_env: ${e.key_env}`) } return lines.join("\n") } function parseFallbackFromConfig() { const text = readConfig() const lines = text.split(/\r?\n/) const startIdx = lines.findIndex((l) => /^fallback_providers:\s*$/.test(l)) if (startIdx === -1) { // Try inline form fallback_providers: [] const inline = lines.find((l) => /^fallback_providers:\s*\[\s*\]\s*$/.test(l)) if (inline) return [] return null } const entries = [] let cur = null for (let i = startIdx + 1; i < lines.length; i++) { const l = lines[i] if (/^\s*-\s+provider:\s*(.+?)\s*$/.test(l)) { if (cur) entries.push(cur) cur = { provider: RegExp.$1.replace(/^["']|["']$/g, "") } continue } if (cur && /^\s+model:\s*(.+?)\s*$/.test(l)) { cur.model = RegExp.$1.replace(/^["']|["']$/g, ""); continue } if (cur && /^\s+base_url:\s*(.+?)\s*$/.test(l)) { cur.base_url = RegExp.$1.replace(/^["']|["']$/g, ""); continue } if (cur && /^\s+key_env:\s*(.+?)\s*$/.test(l)) { cur.key_env = RegExp.$1.replace(/^["']|["']$/g, ""); continue } // End conditions if (l.length === 0) continue if (/^[#\s]/.test(l)) continue break } if (cur) entries.push(cur) return entries } function setModelInConfig(provider, model) { let text = readConfig() // Replace within model: block — find `^model:\s*$`, then patch provider/default keys. const lines = text.split(/\r?\n/) const modelIdx = lines.findIndex((l) => /^model:\s*$/.test(l)) if (modelIdx === -1) { // Prepend const block = `model:\n provider: "${provider}"\n default: "${model}"\n\n` text = block + text writeConfig(text) return } let providerSet = false let defaultSet = false for (let i = modelIdx + 1; i < lines.length; i++) { const l = lines[i] if (/^\S/.test(l)) break if (/^\s+provider:\s*/.test(l) && !providerSet) { lines[i] = ` provider: "${provider}"` providerSet = true } else if (/^\s+default:\s*/.test(l) && !defaultSet) { lines[i] = ` default: "${model}"` defaultSet = true } } if (!providerSet) { lines.splice(modelIdx + 1, 0, ` provider: "${provider}"`) providerSet = true } if (!defaultSet) { lines.splice(modelIdx + 2, 0, ` default: "${model}"`) } writeConfig(lines.join("\n")) } function readModelFromConfig() { const text = readConfig() const lines = text.split(/\r?\n/) const modelIdx = lines.findIndex((l) => /^model:\s*$/.test(l)) let provider = null, model = null if (modelIdx === -1) return { provider, model } for (let i = modelIdx + 1; i < lines.length; i++) { const l = lines[i] if (/^\S/.test(l)) break let m if ((m = l.match(/^\s+provider:\s*"?([^"\s#]+)"?/))) provider = provider ?? m[1] if ((m = l.match(/^\s+default:\s*"?([^"\s#]+)"?/))) model = model ?? m[1] } return { provider, model } } // ─── Output parsers ─────────────────────────────────────────────────────── function parseAuthList(text) { const pools = [] let current = null for (const raw of text.split(/\r?\n/)) { const line = raw.trimEnd() if (!line) continue const header = line.match(/^([\w:.-]+)\s+\((\d+)\s+credentials?\):$/) if (header) { current = { provider: header[1], count: Number(header[2]), entries: [] } pools.push(current) continue } if (current && /^\s*#(\d+)\s+(\S.*)$/.test(line)) { const m = line.match(/^\s*#(\d+)\s+(.*)$/) current.entries.push({ index: Number(m[1]), raw: m[2].trim(), active: m[2].includes("←") }) } } return pools } function decodeJwtPayload(token) { if (!token || typeof token !== "string") return null const parts = token.split(".") if (parts.length < 2) return null try { const payload = parts[1].replace(/-/g, "+").replace(/_/g, "/") return JSON.parse(Buffer.from(payload, "base64").toString("utf-8")) } catch { return null } } function identityFromClaims(claims) { if (!claims || typeof claims !== "object") return null const email = [claims.email, claims.preferred_username, claims.upn] .find((v) => typeof v === "string" && v.includes("@")) const name = [claims.name, claims.given_name, claims.nickname] .find((v) => typeof v === "string" && v.trim()) const id = [claims.account_id, claims.sub, claims.user_id] .find((v) => typeof v === "string" && v.trim()) if (!email && !name && !id) return null return { email: email || null, name: name || null, id: id || null } } function identityFromToken(...tokens) { for (const token of tokens) { const identity = identityFromClaims(decodeJwtPayload(token)) if (identity) return identity } return null } function safeJson(p) { try { return JSON.parse(fs.readFileSync(p, "utf-8")) } catch { return null } } function listAuthJsonFiles(dir) { try { return fs.readdirSync(dir) .filter((file) => file.startsWith("auth") && file.endsWith(".json")) .sort() } catch { return [] } } function normalizeLabel(value) { return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim() } function identitiesFromJson(value, identities = []) { if (!value || typeof value !== "object") return identities const direct = identityFromClaims(value) if (direct) identities.push(direct) if (Array.isArray(value)) { for (const item of value) identitiesFromJson(item, identities) return identities } for (const [key, item] of Object.entries(value)) { if (typeof item === "string") { const identity = identityFromToken(item) if (identity) identities.push(identity) continue } if (item && typeof item === "object") identitiesFromJson(item, identities) } return identities } function uniqueIdentities(identities) { const seen = new Set() const result = [] for (const identity of identities) { const key = identity.email || identity.name || identity.id if (!key || seen.has(key)) continue seen.add(key) result.push(identity) } return result } function authFileHints(file) { const suffix = file.slice(4, -5) // "auth.json" -> "Suffix" return suffix ? [suffix.toLowerCase()] : ["main", "default"] } function readCliAuthIdentities(provider, dir) { const identities = [] for (const file of listAuthJsonFiles(dir)) { const data = safeJson(path.join(dir, file)) const fileIdentities = uniqueIdentities(identitiesFromJson(data)) const identity = fileIdentities[0] || {} identities.push({ provider, file, source: "mounted-auth", hints: authFileHints(file), label: file.replace(/\.json$/, ""), ...identity, }) } return identities } function readCodexCliIdentities() { return readCliAuthIdentities("openai-codex", CODEX_HOME) } function readHermesAuthIdentities() { const store = safeJson(path.join(HERMES_HOME, "auth.json")) || {} const identities = [] const pool = store.credential_pool && typeof store.credential_pool === "object" ? store.credential_pool : {} for (const [provider, entries] of Object.entries(pool)) { if (!Array.isArray(entries)) continue for (const entry of entries) { if (!entry || typeof entry !== "object") continue const identity = identityFromToken( entry.id_token, entry.access_token, entry.refresh_token, entry.agent_key ) if (identity) { identities.push({ provider, label: entry.label || "", entryId: entry.id || "", ...identity }) } } } return identities } function matchIdentity(provider, entry, identities) { const candidates = identities.filter((i) => i.provider === provider) if (!candidates.length) return null const raw = normalizeLabel(entry.raw) for (const candidate of candidates) { if (candidate.entryId && raw.includes(normalizeLabel(candidate.entryId))) return candidate } for (const candidate of candidates) { if ((candidate.hints || []).some((hint) => raw.includes(normalizeLabel(hint)))) return candidate } for (const candidate of candidates) { if (candidate.label && raw.includes(normalizeLabel(candidate.label))) return candidate } return candidates[entry.index - 1] || null } function authStateForPool(pool) { const entries = Array.isArray(pool.entries) ? pool.entries : [] if (!entries.length) { return { state: "unauthenticated", label: "Unauthenticated" } } const raw = entries.map((entry) => entry.raw || "").join(" ").toLowerCase() if (/\b(exhausted|quota|usage limit|monthly limit|rate[-\s]?limit|429)\b/.test(raw)) { return { state: "usage_limited", label: "Usage limit reached" } } if (/\b(expired|revoked|invalid|unauthorized|auth failed|401|403)\b/.test(raw)) { return { state: "error", label: "Auth needs attention" } } return { state: "authenticated", label: "Authenticated" } } function attachAuthStates(pools) { return pools.map((pool) => ({ ...pool, count: pool.count ?? (Array.isArray(pool.entries) ? pool.entries.length : 0), authState: authStateForPool(pool), })) } function enrichAuthPools(pools) { const identities = [ ...readCodexCliIdentities(), ...readCliAuthIdentities("anthropic", CLAUDE_CONFIG_DIR), ...readCliAuthIdentities("google-gemini-cli", GEMINI_CONFIG_DIR), ...readHermesAuthIdentities(), ] const enriched = pools.map((pool) => ({ ...pool, entries: pool.entries.map((entry) => { const identity = matchIdentity(pool.provider, entry, identities) if (!identity) return entry return { ...entry, email: identity.email || null, name: identity.name || null, identity: identity.email || identity.name || identity.id || null, } }) })) const existingProviders = new Set(enriched.map((pool) => pool.provider)) for (const provider of ["anthropic", "openai-codex", "google-gemini-cli"]) { if (existingProviders.has(provider)) continue const entries = identities .filter((identity) => identity.provider === provider) .map((identity, index) => ({ index: index + 1, raw: identity.label || identity.file || identity.email || identity.name || "mounted auth", active: index === 0, file: identity.file || "", email: identity.email || null, name: identity.name || null, identity: identity.email || identity.name || identity.id || null, source: "mounted-auth", })) if (entries.length) { enriched.push({ provider, count: entries.length, entries }) existingProviders.add(provider) } } return attachAuthStates(enriched) } function authDirForProvider(provider) { switch (provider) { case "openai-codex": return CODEX_HOME case "anthropic": return CLAUDE_CONFIG_DIR case "google-gemini-cli": return GEMINI_CONFIG_DIR default: return null } } function mountedAuthFile(provider, file) { const dir = authDirForProvider(provider) const base = path.basename(String(file || "")) if (!dir || !base.startsWith("auth") || !base.endsWith(".json") || base !== file) return null return path.join(dir, base) } function moveMountedAuthToBackup(file) { const dir = path.dirname(file) const backupDir = path.join(dir, ".hermes-control-plane-deleted-auth") fs.mkdirSync(backupDir, { recursive: true }) const stamp = new Date().toISOString().replace(/[:.]/g, "-") const base = path.basename(file) let target = path.join(backupDir, `${stamp}-${base}`) let suffix = 1 while (fs.existsSync(target)) { target = path.join(backupDir, `${stamp}-${suffix}-${base}`) suffix += 1 } fs.renameSync(file, target) return target } function parseFallback(text) { const entries = [] for (const line of text.split(/\r?\n/)) { const m = line.match(/^\s*(\d+)\.\s+(\S+)\s+\(via\s+([^)]+)\)/) if (m) entries.push({ index: Number(m[1]), model: m[2], provider: m[3] }) } const primary = (text.match(/Primary:\s+(\S+)\s+\(via\s+([^)]+)\)/) || []).slice(1, 3) return { primary: primary.length === 2 ? { model: primary[0], provider: primary[1] } : null, entries } } function parseSkillsList(text) { // Strip box-drawing chars; rows separated by │ const skills = [] for (const line of text.split(/\r?\n/)) { const cells = line.split("│").map((s) => s.trim()).filter(Boolean) if (cells.length >= 5 && cells[0] && cells[0] !== "Name" && !/^[─┬┴┼─]+$/.test(cells[0])) { skills.push({ name: cells[0], category: cells[1] || "", source: cells[2] || "", trust: cells[3] || "", status: cells[4] || "" }) } } return skills } function parseMcpList(text) { if (/No MCP servers configured/i.test(text)) return [] // Format varies; tolerate const entries = [] let current = null for (const line of text.split(/\r?\n/)) { const head = line.match(/^\s*•?\s*([\w_-]+)\s*$/) if (head && !/^[╭╰─│]/.test(line)) { if (current) entries.push(current) current = { name: head[1], detail: "" } continue } if (current && line.trim()) current.detail += line.trim() + " " } if (current) entries.push(current) return entries } function parseCronList(text) { if (/No scheduled jobs/i.test(text)) return [] // CLI prints a table; parse rows by ID const lines = text.split(/\r?\n/) const jobs = [] for (const l of lines) { const m = l.match(/^\s*([a-f0-9]{6,32})\s+(.+?)\s+\|\s+(.+?)\s+\|\s+(.+)$/) if (m) jobs.push({ id: m[1], schedule: m[2].trim(), status: m[3].trim(), prompt: m[4].trim() }) } return jobs } function parseSessions(text) { const sessions = [] for (const line of text.split(/\r?\n/)) { // session_YYYYMMDD_HHMMSS_uuid const m = line.match(/(session_\d{8}_\d{6}_[a-f0-9-]+)/) if (m) sessions.push({ id: m[1] }) } return sessions } function envHas(key) { if (!fs.existsSync(ENV_FILE)) return false const txt = fs.readFileSync(ENV_FILE, "utf-8") return new RegExp(`^${key}=.+`, "m").test(txt) } function envSet(key, value) { let txt = fs.existsSync(ENV_FILE) ? fs.readFileSync(ENV_FILE, "utf-8") : "" const re = new RegExp(`^${key}=.*$`, "m") const line = `${key}=${value}` if (re.test(txt)) txt = txt.replace(re, line) else txt += (txt && !txt.endsWith("\n") ? "\n" : "") + line + "\n" fs.writeFileSync(ENV_FILE, txt, "utf-8") } // ─── Endpoint handlers ──────────────────────────────────────────────────── const SUPPORTED_OAUTH = new Set(["anthropic", "openai-codex", "google-gemini-cli", "nous", "xai-oauth", "qwen-oauth", "minimax-oauth"]) async function h_status(_req, res) { const [list, fb, ver] = await Promise.all([ runHermes(["auth", "list"]), runHermes(["fallback", "list"]), runHermes(["version"]) ]) const cfg = readModelFromConfig() send(res, 200, { hermesExePresent: fs.existsSync(HERMES_EXE), pools: enrichAuthPools(parseAuthList(list.stdout + list.stderr)), fallback: parseFallback(fb.stdout + fb.stderr), deepseekConfigured: envHas("DEEPSEEK_API_KEY"), version: (ver.stdout || ver.stderr).trim().split(/\r?\n/)[0], primary: cfg }) } async function h_addOauth(req, res) { const body = await readBody(req) const provider = String(body.provider || "") if (!SUPPORTED_OAUTH.has(provider)) return send(res, 400, { error: `Unsupported: ${provider}` }) const existing = runningProcs.get(provider) if (existing && !existing.done) return send(res, 409, { error: "already running" }) if (provider === "openai-codex") startCodexDeviceOauth() else spawnHermesTracked(provider, ["auth", "add", provider, "--type", "oauth"]) send(res, 202, { status: "started", provider }) } async function h_addProgress(req, res) { const url = new URL(req.url, `http://${HOST}`) const provider = url.searchParams.get("provider") || "" const state = runningProcs.get(provider) if (!state) return send(res, 404, { error: "no run" }) send(res, 200, { done: state.done, exitCode: state.exitCode, aborted: state.aborted, output: state.lines.join(""), elapsedMs: Date.now() - state.startedAt }) } async function h_cancelOauth(req, res) { const body = await readBody(req) const provider = String(body.provider || "") const state = runningProcs.get(provider) if (!state || state.done) return send(res, 404, { error: "no active oauth" }) state.aborted = true try { state.proc.kill("SIGTERM") } catch {} // Force kill on Windows after 1s if still alive setTimeout(() => { if (!state.done) { try { state.proc.kill("SIGKILL") } catch {} try { // Windows fallback spawn("taskkill", ["/PID", String(state.proc.pid), "/F", "/T"], { windowsHide: true }) } catch {} } }, 1000) send(res, 200, { ok: true }) } async function h_submitOauthInput(req, res) { const body = await readBody(req) const provider = String(body.provider || "") const value = String(body.value || body.callback_url || "").trim() const state = runningProcs.get(provider) if (!provider || !value) return send(res, 400, { error: "provider and value required" }) if (provider === "google-gemini-cli") { let callback try { callback = new URL(value) } catch {} if (callback) { const localHost = callback.hostname === "127.0.0.1" || callback.hostname === "localhost" if (!localHost || callback.protocol !== "http:" || callback.pathname !== "/oauth2callback") { return send(res, 400, { error: "Gemini callback must be the localhost /oauth2callback URL from Google" }) } try { const result = await new Promise((resolve, reject) => { const request = http.get(callback, (response) => { response.resume() response.on("end", () => resolve(response.statusCode || 0)) }) request.setTimeout(5000, () => request.destroy(new Error("callback forwarding timed out"))) request.on("error", reject) }) return send(res, 200, { ok: true, forwardedStatus: result }) } catch (err) { return send(res, 502, { error: `Callback forwarding failed: ${err.message}` }) } } } if (!state || state.done) return send(res, 404, { error: "no active oauth" }) if (!state.proc?.stdin?.writable) return send(res, 409, { error: "OAuth flow is not accepting input" }) state.proc.stdin.write(value + "\n") send(res, 200, { ok: true }) } async function h_addApiKey(req, res) { const body = await readBody(req) const provider = String(body.provider || "") const apiKey = String(body.api_key || "").trim() if (!provider || !apiKey) return send(res, 400, { error: "provider and api_key required" }) const result = await runHermes(["auth", "add", provider, "--type", "api-key", "--api-key", apiKey]) send(res, 200, { exitCode: result.code, output: result.stdout + result.stderr }) } async function h_authRemove(req, res) { const body = await readBody(req) const provider = String(body.provider || "") const index = Number(body.index || 0) if (!provider || !index) return send(res, 400, { error: "provider + index required" }) if (body.source === "mounted-auth") { const file = mountedAuthFile(provider, body.file) if (!file) return send(res, 400, { error: "invalid mounted auth file" }) try { const backup = moveMountedAuthToBackup(file) return send(res, 200, { exitCode: 0, output: `moved ${path.basename(file)} to ${backup}` }) } catch (err) { return send(res, 200, { exitCode: -1, error: `Remove failed: ${err.message}`, output: err.message }) } } const result = await runHermes(["auth", "remove", provider, String(index)]) send(res, 200, { exitCode: result.code, output: result.stdout + result.stderr }) } async function h_authReset(req, res) { const body = await readBody(req) const provider = String(body.provider || "") if (!provider) return send(res, 400, { error: "provider required" }) const result = await runHermes(["auth", "reset", provider]) send(res, 200, { exitCode: result.code, output: result.stdout + result.stderr }) } async function h_setDeepseek(req, res) { const body = await readBody(req) const key = String(body.key || "").trim() if (!key) return send(res, 400, { error: "key required" }) if (!/^sk-[A-Za-z0-9_\-]{8,}$/.test(key)) return send(res, 400, { error: "key shape looks wrong (expected sk-...)" }) envSet("DEEPSEEK_API_KEY", key) send(res, 200, { ok: true }) } async function h_fallbackList(_req, res) { const fromCli = await runHermes(["fallback", "list"]) const fromFile = parseFallbackFromConfig() send(res, 200, { cli: parseFallback(fromCli.stdout + fromCli.stderr), file: fromFile }) } async function h_fallbackSet(req, res) { const body = await readBody(req) const entries = Array.isArray(body.entries) ? body.entries : null if (!entries) return send(res, 400, { error: "entries[] required" }) for (const e of entries) { if (!e.provider || !e.model) return send(res, 400, { error: "each entry needs provider + model" }) } const text = readConfig() writeConfig(replaceFallbackBlock(text, entries)) send(res, 200, { ok: true, count: entries.length }) } async function h_modelSet(req, res) { const body = await readBody(req) const provider = String(body.provider || "") const model = String(body.model || "") if (!provider || !model) return send(res, 400, { error: "provider + model required" }) setModelInConfig(provider, model) send(res, 200, { ok: true, provider, model }) } async function h_skillsList(_req, res) { const r = await runHermes(["skills", "list"]) send(res, 200, { skills: parseSkillsList(r.stdout + r.stderr) }) } async function h_skillToggle(req, res) { const body = await readBody(req) const name = String(body.name || "") const action = String(body.action || "") if (!name || !["install", "uninstall", "opt-in", "opt-out", "reset"].includes(action)) { return send(res, 400, { error: "name + valid action required" }) } const r = await runHermes(["skills", action, name], 60000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_mcpList(_req, res) { const r = await runHermes(["mcp", "list"]) send(res, 200, { servers: parseMcpList(r.stdout + r.stderr), raw: r.stdout + r.stderr }) } // Inline Python that registers a URL-mode MCP server via hermes's own config // helpers. Writes the bearer token to ~/.hermes/.env (mode 600) and stores only // an env-interpolated placeholder in config.yaml, so the raw token never lands // in plaintext config. No live probe — registration is instant and never hangs // on an auth challenge. argv: . Secrets arrive via env (HCP_*). const PY_MCP_URL_ADD = ` import json, os, re, sys from hermes_cli.config import load_config, save_config, save_env_value name, url = sys.argv[1], sys.argv[2] if not re.match(r"^[A-Za-z0-9_-]+$", name): print("invalid server name", file=sys.stderr); sys.exit(2) token = os.environ.get("HCP_MCP_TOKEN") or "" try: extra = json.loads(os.environ.get("HCP_MCP_HEADERS") or "{}") if not isinstance(extra, dict): extra = {} except Exception: extra = {} headers = {} # Custom headers first; the token (if any) owns Authorization and wins. for k, v in extra.items(): if k and isinstance(v, str): headers[str(k)] = v if token: env_key = "MCP_%s_API_KEY" % re.sub(r"[^A-Za-z0-9]", "_", name).upper() save_env_value(env_key, token) headers["Authorization"] = "Bearer \${%s}" % env_key server = {"url": url, "enabled": True} if headers: server["headers"] = headers cfg = load_config() cfg.setdefault("mcp_servers", {})[name] = server save_config(cfg) print(json.dumps({"ok": True, "name": name, "auth": bool(token), "headers": sorted(headers)})) ` // Parse a free-form "Name: Value" headers blob (one per line) into an object. function parseHeaderLines(raw) { const out = {} for (const line of String(raw || "").split(/\r?\n/)) { const trimmed = line.trim() if (!trimmed) continue const idx = trimmed.indexOf(":") if (idx <= 0) continue const key = trimmed.slice(0, idx).trim() const val = trimmed.slice(idx + 1).trim() if (key) out[key] = val } return out } // Register a URL-mode MCP server without any network probe (non-blocking). function mcpWriteUrlServer({ name, url, token, headers }, timeoutMs = 15000) { return new Promise((resolve) => { // No cwd override: the venv's editable .pth resolves `hermes_cli` imports // regardless of cwd, and the hermes source dir differs between the local // install ($HERMES_HOME/hermes-agent) and the container (/opt/hermes-agent) // — pinning a cwd that may not exist would make spawn fail with ENOENT. const proc = spawn(HERMES_PYTHON, ["-c", PY_MCP_URL_ADD, name, url], { env: { ...process.env, NO_COLOR: "1", PYTHONIOENCODING: "utf-8", HCP_MCP_TOKEN: token || "", HCP_MCP_HEADERS: JSON.stringify(headers || {}), }, windowsHide: true, }) let stdout = "", stderr = "", settled = false const settle = (r) => { if (!settled) { settled = true; resolve(r) } } const timer = setTimeout(() => { try { proc.kill("SIGKILL") } catch {} settle({ code: -1, stdout, stderr: stderr + "\ntimeout" }) }, timeoutMs) proc.stdout.on("data", (d) => { stdout += d.toString("utf-8") }) proc.stderr.on("data", (d) => { stderr += d.toString("utf-8") }) proc.on("close", (code) => { clearTimeout(timer); settle({ code, stdout, stderr }) }) proc.on("error", (err) => { clearTimeout(timer); settle({ code: -1, stdout, stderr: stderr + "\n" + err.message }) }) }) } async function h_mcpAdd(req, res) { const body = await readBody(req) const name = String(body.name || "").trim() const url = String(body.url || "").trim() const command = String(body.command || "").trim() if (!name) return send(res, 400, { error: "name required" }) if (!/^[A-Za-z0-9_-]+$/.test(name)) { return send(res, 400, { error: "name may only contain letters, numbers, '-' and '_'" }) } if (url) { if (!/^https?:\/\//i.test(url)) { return send(res, 400, { error: "url must start with http:// or https://" }) } // Auth/headers may arrive as a token string and/or a headers blob (object // or "Name: Value" lines). Token → bearer in .env; headers → inline config. const token = String(body.token || "").trim() let headers = {} if (body.headers && typeof body.headers === "object") headers = body.headers else if (typeof body.headers === "string") headers = parseHeaderLines(body.headers) // Direct config write — no interactive prompt, no network probe, no hang. const r = await mcpWriteUrlServer({ name, url, token, headers }) if (r.code === 0) { return send(res, 200, { exitCode: 0, output: `✓ Registered '${name}' (${url})${token ? " with bearer auth" : ""}.\n` + `Use “Test” to verify the connection.\n`, }) } return send(res, 200, { exitCode: r.code, output: (r.stdout + r.stderr).trim() || "Failed to write MCP server config.", }) } if (command) { const extra = Array.isArray(body.args) ? body.args : [] const args = ["mcp", "add", name, "--command", command, ...(extra.length ? ["--args", ...extra] : [])] const r = await runHermes(args, 30000) return send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } return send(res, 400, { error: "url or command required" }) } // Probe an already-registered MCP server (used by the UI "Test" button). async function h_mcpTest(req, res) { const body = await readBody(req) const name = String(body.name || "").trim() if (!name) return send(res, 400, { error: "name required" }) const r = await runHermes(["mcp", "test", name], 45000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_mcpRemove(req, res) { const body = await readBody(req) const name = String(body.name || "") if (!name) return send(res, 400, { error: "name required" }) const r = await runHermes(["mcp", "remove", name]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_cronList(_req, res) { const r = await runHermes(["cron", "list"]) send(res, 200, { jobs: parseCronList(r.stdout + r.stderr), raw: r.stdout + r.stderr }) } async function h_cronAction(req, res) { const body = await readBody(req) const id = String(body.id || "") const action = String(body.action || "") if (!id || !["pause", "resume", "run", "remove"].includes(action)) { return send(res, 400, { error: "id + valid action required" }) } const r = await runHermes(["cron", action, id]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_sessionsList(_req, res) { const r = await runHermes(["sessions", "list"]) send(res, 200, { sessions: parseSessions(r.stdout + r.stderr), raw: r.stdout + r.stderr }) } async function h_sessionsPrune(req, res) { const body = await readBody(req) const days = Number(body.days || 30) const r = await runHermes(["sessions", "prune", "--older-than", String(days)]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_tools(_req, res) { const r = await runHermes(["tools", "--summary"]) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_toolToggle(req, res) { const body = await readBody(req) const platform = String(body.platform || "") const name = String(body.name || "") const enable = !!body.enable if (!platform || !name) return send(res, 400, { error: "platform + name required" }) const args = ["tools", enable ? "enable" : "disable", "--platform", platform, name] const r = await runHermes(args) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_doctor(_req, res) { const r = await runHermes(["doctor"], 60000) send(res, 200, { exitCode: r.code, raw: r.stdout + r.stderr }) } async function h_doSystemStatus(_req, res) { const r = await runHermes(["status"]) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_logs(_req, res) { const r = await runHermes(["logs", "--tail", "200"], 15000) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_paths(_req, res) { send(res, 200, { hermesHome: HERMES_HOME, hermesExe: HERMES_EXE, config: CONFIG_FILE, env: ENV_FILE, backupsDir: HERMES_HOME }) } // ─── Filesystem helpers ─────────────────────────────────────────────────── const HERMES_AGENT_DIR = path.join(HERMES_HOME, "hermes-agent") const BUNDLED_PLUGINS_DIR = path.join(HERMES_AGENT_DIR, "plugins") const USER_PLUGINS_DIR = path.join(HERMES_HOME, "plugins") const SKILLS_DIR = path.join(HERMES_HOME, "skills") const BUNDLED_MANIFEST = path.join(SKILLS_DIR, ".bundled_manifest") const USER_BUNDLES_DIR = path.join(HERMES_HOME, "skill-bundles") function safeRead(p) { try { return fs.readFileSync(p, "utf-8") } catch { return null } } function safeReaddir(p) { try { return fs.readdirSync(p, { withFileTypes: true }) } catch { return [] } } /** Minimal YAML frontmatter parser — top-level scalars + inline arrays. */ function parseFrontmatter(text) { if (!text) return null const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/) if (!m) return null const out = {} const lines = m[1].split(/\r?\n/) let parentKey = null for (const raw of lines) { if (!raw.trim() || raw.trim().startsWith("#")) { parentKey = null; continue } // Nested: 2-space indented key const indent = raw.match(/^(\s*)/)[1].length const trimmed = raw.trim() const kv = trimmed.match(/^([\w-]+):\s*(.*)$/) if (!kv) continue const [, key, valRaw] = kv let value = valRaw.trim() if (indent === 0) { parentKey = null if (value === "" || value.startsWith("{") || value.startsWith("|") || value.startsWith(">")) { // start of block / nested map parentKey = key out[key] = out[key] || {} continue } // Inline array if (value.startsWith("[") && value.endsWith("]")) { out[key] = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean) continue } // Quoted string if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { out[key] = value.slice(1, -1) continue } out[key] = value } else if (parentKey) { // Flatten one level: parentKey.subkey = value if (value.startsWith("[") && value.endsWith("]")) { out[`${parentKey}.${key}`] = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^["']|["']$/g, "")).filter(Boolean) } else { out[`${parentKey}.${key}`] = value.replace(/^["']|["']$/g, "") } } } return out } function readPluginManifest(dir) { for (const fname of ["plugin.yaml", "plugin.yml", "manifest.json"]) { const p = path.join(dir, fname) if (!fs.existsSync(p)) continue if (fname.endsWith(".json")) { try { return JSON.parse(fs.readFileSync(p, "utf-8")) } catch { return null } } // YAML — use frontmatter-style parser (close enough; plugin.yamls are flat) const text = fs.readFileSync(p, "utf-8") return parseFrontmatter("---\n" + text + "\n---") || parseFrontmatter(text) } return null } function walkPluginDirs(rootDir, depth = 0) { // plugins may be nested 1 level (category/plugin) or flat. Look for plugin.yaml at depth 1 or 2. const out = [] if (!fs.existsSync(rootDir)) return out for (const ent of safeReaddir(rootDir)) { if (!ent.isDirectory() || ent.name.startsWith(".") || ent.name === "__pycache__") continue const dir = path.join(rootDir, ent.name) const m = readPluginManifest(dir) if (m) { out.push({ ...m, _dir: dir, _category: depth > 0 ? path.basename(rootDir) : null }) continue } // recurse one level for category dirs if (depth < 1) out.push(...walkPluginDirs(dir, depth + 1)) } return out } function walkSkillDirs(rootDir) { // Skills laid out as ///SKILL.md const out = [] if (!fs.existsSync(rootDir)) return out for (const cat of safeReaddir(rootDir)) { if (!cat.isDirectory() || cat.name.startsWith(".")) continue const catDir = path.join(rootDir, cat.name) for (const sk of safeReaddir(catDir)) { if (!sk.isDirectory() || sk.name.startsWith(".")) continue const sd = path.join(catDir, sk.name) const md = safeRead(path.join(sd, "SKILL.md")) if (!md) continue const fm = parseFrontmatter(md) || {} out.push({ name: fm.name || sk.name, category: cat.name, description: fm.description || "", version: fm.version || "", author: fm.author || "", license: fm.license || "", platforms: fm.platforms || [], tags: fm["metadata.hermes.tags"] || fm.tags || [], prerequisites: fm["prerequisites.commands"] || [], _dir: sd }) } } return out } function httpsGet(url, headers = {}) { return new Promise((resolve) => { const opts = { headers: { "User-Agent": "hermes-control-plane/1.0", "Accept": "application/vnd.github+json", ...headers } } https.get(url, opts, (res) => { let data = "" res.on("data", (c) => { data += c }) res.on("end", () => { try { resolve({ status: res.statusCode, body: JSON.parse(data) }) } catch { resolve({ status: res.statusCode, body: null, raw: data }) } }) }).on("error", (err) => resolve({ status: -1, error: err.message })) }) } // ─── §03 Skills (rich, filesystem-driven) ──────────────────────────────── async function h_skillsRich(_req, res) { const skills = walkSkillDirs(SKILLS_DIR) // Hermes-known disabled state lives in skills opt-out registry — we'll mark each as enabled by default; // the existing /api/skills (CLI) endpoint still tells you the authoritative status. Cheaper to skip merge here. send(res, 200, { skills, total: skills.length }) } async function h_skillsDiscover(req, res) { const url = new URL(req.url, `http://${HOST}`) const q = (url.searchParams.get("q") || "").trim() const source = url.searchParams.get("source") || "all" if (!q) return send(res, 400, { error: "q required" }) const args = ["skills", "search", q, "--source", source, "--limit", "30", "--json"] const r = await runHermes(args, 30000) let results = [] try { const parsed = JSON.parse((r.stdout || "").trim() || "[]") results = Array.isArray(parsed) ? parsed : (parsed.results || parsed.skills || []) } catch { results = [] } send(res, 200, { results, count: results.length, source, query: q }) } async function h_skillInstall(req, res) { const body = await readBody(req) const identifier = String(body.identifier || "") if (!identifier) return send(res, 400, { error: "identifier required" }) const r = await runHermes(["skills", "install", identifier], 120000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §04 Plugins (rich, filesystem-driven) ──────────────────────────────── async function h_pluginsRich(_req, res) { // Authoritative status from CLI JSON const r = await runHermes(["plugins", "list", "--json"]) let cliPlugins = [] try { cliPlugins = JSON.parse(r.stdout.trim() || "[]") } catch { cliPlugins = [] } // Enrich with manifests from disk (hooks, author, etc.) const bundled = walkPluginDirs(BUNDLED_PLUGINS_DIR) const user = walkPluginDirs(USER_PLUGINS_DIR) const allManifests = [...bundled, ...user] const enriched = cliPlugins.map((p) => { const m = allManifests.find((x) => x.name === p.name) return { ...p, author: m?.author || "", hooks: Array.isArray(m?.hooks) ? m.hooks : [], category: m?._category || "", isBundled: p.source === "bundled", isUser: !!user.find((x) => x.name === p.name) } }) send(res, 200, { plugins: enriched, total: enriched.length }) } async function h_pluginsDiscover(req, res) { const url = new URL(req.url, `http://${HOST}`) const q = (url.searchParams.get("q") || "").trim() // Strategy: try GitHub topic search first; if user passes a query, combine. // GitHub query syntax: space-separated qualifiers AND together. No OR for topics. const queries = q ? [`${q} topic:hermes-plugin`, `${q} hermes plugin in:name,description`] : [`topic:hermes-plugin`, `hermes plugin in:name,description+stars:>1`] for (const query of queries) { const ghUrl = `https://api.github.com/search/repositories?q=${encodeURIComponent(query)}&sort=stars&order=desc&per_page=30` const r = await httpsGet(ghUrl) if (r.status === 200) { const items = (r.body?.items || []).map((it) => ({ name: it.name, full_name: it.full_name, description: it.description || "", stars: it.stargazers_count, updated_at: it.updated_at, html_url: it.html_url, topics: it.topics || [], language: it.language || "", owner: it.owner?.login || "" })) if (items.length > 0 || query === queries[queries.length - 1]) { return send(res, 200, { results: items, count: items.length, query, source: "github" }) } } } // All queries failed send(res, 200, { results: [], error: "GitHub search returned no results or rate-limited", query: q }) } // ─── §05 Bundles (rich) ─────────────────────────────────────────────────── async function h_bundlesRich(_req, res) { const manifestText = safeRead(BUNDLED_MANIFEST) || "" const bundled = manifestText.split(/\r?\n/) .map((l) => l.trim()) .filter(Boolean) .map((l) => { const [name, hash] = l.split(":") return { name, hash: hash || "" } }) // User-created bundles let userBundles = [] if (fs.existsSync(USER_BUNDLES_DIR)) { for (const ent of safeReaddir(USER_BUNDLES_DIR)) { if (ent.isDirectory()) { const mPath = path.join(USER_BUNDLES_DIR, ent.name, "bundle.yaml") const meta = safeRead(mPath) userBundles.push({ name: ent.name, meta: meta ? parseFrontmatter("---\n" + meta + "\n---") || {} : null, _dir: path.join(USER_BUNDLES_DIR, ent.name) }) } else if (ent.name.endsWith(".yaml") || ent.name.endsWith(".yml")) { const meta = safeRead(path.join(USER_BUNDLES_DIR, ent.name)) userBundles.push({ name: ent.name.replace(/\.ya?ml$/, ""), meta: meta ? parseFrontmatter("---\n" + meta + "\n---") || {} : null, _file: path.join(USER_BUNDLES_DIR, ent.name) }) } } } // Cross-reference bundled with installed skill metadata const skills = walkSkillDirs(SKILLS_DIR) const skillByName = Object.fromEntries(skills.map((s) => [s.name, s])) const bundledEnriched = bundled.map((b) => ({ name: b.name, hash: b.hash.slice(0, 8), description: skillByName[b.name]?.description || "", category: skillByName[b.name]?.category || "" })) send(res, 200, { bundledOfficialSkills: bundledEnriched, bundledOfficialTotal: bundledEnriched.length, userBundles, userBundlesDir: USER_BUNDLES_DIR }) } async function h_bundleCreate(req, res) { const body = await readBody(req) const name = String(body.name || "").trim() const skills = Array.isArray(body.skills) ? body.skills : [] if (!name || !skills.length) return send(res, 400, { error: "name + skills[] required" }) const args = ["bundles", "create", name] for (const s of skills) args.push("--skill", s) const r = await runHermes(args) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_bundleDelete(req, res) { const body = await readBody(req) const name = String(body.name || "") if (!name) return send(res, 400, { error: "name required" }) const r = await runHermes(["bundles", "delete", name]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §06 Curator (structured) ──────────────────────────────────────────── async function h_curatorRich(_req, res) { const r = await runHermes(["curator", "status"]) const raw = r.stdout + r.stderr const lines = raw.split(/\r?\n/) const stats = {} const counts = {} const leastRecent = [] let mode = "head" for (const l of lines) { const trim = l.trim() if (!trim) continue // Curator on/off line: "curator: ENABLED" const enabled = trim.match(/^curator:\s+(\w+)$/i) if (enabled) { stats.state = enabled[1]; continue } // Section headers if (/^agent-created skills:/i.test(trim)) { const m = trim.match(/^agent-created skills:\s+(\d+)/i) if (m) counts.total = Number(m[1]) mode = "counts" continue } if (/^least recently active/i.test(trim)) { mode = "leastRecent"; continue } // Mode-specific if (mode === "counts") { const m = trim.match(/^(active|stale|archived)\s+(\d+)/i) if (m) counts[m[1].toLowerCase()] = Number(m[2]) continue } if (mode === "leastRecent") { const m = trim.match(/^(\S+)\s+activity=\s*(\d+)\s+use=\s*(\d+).*last_activity=(.+)$/) if (m) leastRecent.push({ name: m[1], activity: Number(m[2]), uses: Number(m[3]), lastActivity: m[4] }) continue } // KV like " runs: 0" (lowercase, padded) const kv = trim.match(/^([a-z][\w\s.-]*?):\s+(.+?)\s*$/) if (kv) stats[kv[1].trim()] = kv[2].trim() } // List archived (separately) const archived = await runHermes(["curator", "list-archived"]) const archivedLines = (archived.stdout + archived.stderr) .split(/\r?\n/) .map((l) => l.trim()) .filter((l) => l && !l.startsWith("─") && !/^archived/i.test(l) && !/^total/i.test(l)) send(res, 200, { stats, counts, leastRecent, archived: archivedLines, raw }) } // ─── §08 Hooks ──────────────────────────────────────────────────────────── async function h_hooks(_req, res) { const r = await runHermes(["hooks", "list"]) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_hooksDoctor(_req, res) { const r = await runHermes(["hooks", "doctor"], 20000) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_hooksTest(req, res) { const body = await readBody(req) const event = String(body.event || "") if (!event) return send(res, 400, { error: "event required" }) const r = await runHermes(["hooks", "test", event], 30000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_hooksRevoke(req, res) { const body = await readBody(req) const cmd = String(body.command || "") if (!cmd) return send(res, 400, { error: "command required" }) const r = await runHermes(["hooks", "revoke", cmd]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §09 Plugins / Bundles / Curator ────────────────────────────────────── async function h_plugins(_req, res) { const [p, b, c] = await Promise.all([ runHermes(["plugins", "list"]), runHermes(["bundles", "list"]), runHermes(["curator", "status"]) ]) send(res, 200, { plugins: p.stdout + p.stderr, bundles: b.stdout + b.stderr, curator: c.stdout + c.stderr }) } async function h_pluginInstall(req, res) { const body = await readBody(req) const target = String(body.target || "") if (!target) return send(res, 400, { error: "target (URL or owner/repo) required" }) const r = await runHermes(["plugins", "install", target], 120000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_pluginAction(req, res) { const body = await readBody(req) const action = String(body.action || "") const name = String(body.name || "") if (!["update", "remove", "enable", "disable"].includes(action)) return send(res, 400, { error: "bad action" }) if (action !== "update" && !name) return send(res, 400, { error: "name required" }) const args = name ? ["plugins", action, name] : ["plugins", action] const r = await runHermes(args, 90000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_curatorAction(req, res) { const body = await readBody(req) const action = String(body.action || "") if (!["run", "pause", "resume", "prune"].includes(action)) return send(res, 400, { error: "bad action" }) const r = await runHermes(["curator", action], 60000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §10 Memory ────────────────────────────────────────────────────────── function readMemFile(name) { const p = path.join(HERMES_HOME, "memories", name) if (!fs.existsSync(p)) return { exists: false, content: "" } return { exists: true, content: fs.readFileSync(p, "utf-8") } } function writeMemFile(name, content) { const dir = path.join(HERMES_HOME, "memories") if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }) fs.writeFileSync(path.join(dir, name), content, "utf-8") } async function h_memory(_req, res) { const status = await runHermes(["memory", "status"]) send(res, 200, { status: status.stdout + status.stderr, memory: readMemFile("MEMORY.md"), user: readMemFile("USER.md") }) } async function h_memorySave(req, res) { const body = await readBody(req) const file = String(body.file || "") if (!["MEMORY.md", "USER.md"].includes(file)) return send(res, 400, { error: "file must be MEMORY.md or USER.md" }) writeMemFile(file, String(body.content ?? "")) send(res, 200, { ok: true }) } async function h_memoryOff(_req, res) { const r = await runHermes(["memory", "off"]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §11 Kanban ─────────────────────────────────────────────────────────── async function h_kanban(_req, res) { const [tasks, boards] = await Promise.all([ runHermes(["kanban", "list"]), runHermes(["kanban", "boards"]) ]) send(res, 200, { tasks: tasks.stdout + tasks.stderr, boards: boards.stdout + boards.stderr }) } async function h_kanbanCreate(req, res) { const body = await readBody(req) const title = String(body.title || "") const board = String(body.board || "") if (!title) return send(res, 400, { error: "title required" }) const args = ["kanban", "create", "--title", title] if (board) args.unshift("--board", board) const r = await runHermes(args, 60000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §12 Webhooks ───────────────────────────────────────────────────────── async function h_webhooks(_req, res) { const r = await runHermes(["webhook", "list"]) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_webhookAdd(req, res) { const body = await readBody(req) const url = String(body.url || "") const event = String(body.event || "") if (!url) return send(res, 400, { error: "url required" }) const args = ["webhook", "add", "--url", url] if (event) args.push("--event", event) const r = await runHermes(args) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_webhookRemove(req, res) { const body = await readBody(req) const id = String(body.id || "") if (!id) return send(res, 400, { error: "id required" }) const r = await runHermes(["webhook", "remove", id]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_webhookTest(req, res) { const body = await readBody(req) const target = String(body.target || "") if (!target) return send(res, 400, { error: "target id or url required" }) const r = await runHermes(["webhook", "test", target], 20000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §13 Profiles / Pairing ─────────────────────────────────────────────── async function h_profiles(_req, res) { const [p, pair] = await Promise.all([ runHermes(["profile", "list"]), runHermes(["pairing", "list"]) ]) send(res, 200, { profiles: p.stdout + p.stderr, pairing: pair.stdout + pair.stderr }) } async function h_profileAction(req, res) { const body = await readBody(req) const action = String(body.action || "") const name = String(body.name || "") if (!["use", "create", "delete", "describe", "info"].includes(action)) return send(res, 400, { error: "bad action" }) if (!name) return send(res, 400, { error: "name required" }) const r = await runHermes(["profile", action, name], 30000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_pairingAction(req, res) { const body = await readBody(req) const action = String(body.action || "") const subject = String(body.subject || "") if (!["approve", "revoke", "clear-pending"].includes(action)) return send(res, 400, { error: "bad action" }) const args = ["pairing", action] if (subject) args.push(subject) const r = await runHermes(args) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §14 Config ─────────────────────────────────────────────────────────── async function h_configShow(_req, res) { const r = await runHermes(["config", "show"], 15000) send(res, 200, { raw: r.stdout + r.stderr, path: CONFIG_FILE }) } async function h_configCheck(_req, res) { const r = await runHermes(["config", "check"], 15000) send(res, 200, { exitCode: r.code, raw: r.stdout + r.stderr }) } async function h_configMigrate(_req, res) { const r = await runHermes(["config", "migrate"], 30000) send(res, 200, { exitCode: r.code, raw: r.stdout + r.stderr }) } async function h_configSet(req, res) { const body = await readBody(req) const key = String(body.key || "") const value = String(body.value || "") if (!key) return send(res, 400, { error: "key required" }) const r = await runHermes(["config", "set", key, value]) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §15 Security ───────────────────────────────────────────────────────── async function h_security(_req, res) { const r = await runHermes(["security", "audit"], 120000) send(res, 200, { exitCode: r.code, raw: r.stdout + r.stderr }) } // ─── §16 Storage (Backup + Checkpoints) ────────────────────────────────── async function h_backup(req, res) { const body = await readBody(req) const quick = !!body.quick const label = String(body.label || "") const args = ["backup"] if (quick) args.push("--quick") if (label) args.push("--label", label) const r = await runHermes(args, 180000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } async function h_checkpoints(_req, res) { const r = await runHermes(["checkpoints", "status"]) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_checkpointsAction(req, res) { const body = await readBody(req) const action = String(body.action || "") if (!["prune", "clear"].includes(action)) return send(res, 400, { error: "bad action" }) const r = await runHermes(["checkpoints", action, "--yes"], 60000) send(res, 200, { exitCode: r.code, output: r.stdout + r.stderr }) } // ─── §17 Insights ───────────────────────────────────────────────────────── async function h_insights(req, res) { const url = new URL(req.url, `http://${HOST}`) const days = Number(url.searchParams.get("days") || 30) const source = url.searchParams.get("source") const args = ["insights", "--days", String(days)] if (source) args.push("--source", source) const r = await runHermes(args, 60000) send(res, 200, { raw: r.stdout + r.stderr }) } // ─── §18 System extras ──────────────────────────────────────────────────── async function h_dump(_req, res) { const r = await runHermes(["dump"], 20000) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_promptSize(req, res) { const url = new URL(req.url, `http://${HOST}`) const platform = url.searchParams.get("platform") || "cli" const r = await runHermes(["prompt-size", "--platform", platform], 20000) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_version(_req, res) { const r = await runHermes(["version"]) send(res, 200, { raw: r.stdout + r.stderr }) } async function h_update(_req, res) { const r = await runHermes(["update"], 300000) send(res, 200, { exitCode: r.code, raw: r.stdout + r.stderr }) } async function h_portal(_req, res) { const r = await runHermes(["portal", "info"], 10000) send(res, 200, { raw: r.stdout + r.stderr }) } // ─── Admin auth middleware ──────────────────────────────────────────────── async function requireAdmin(req, res) { // If auth is not configured, allow all access if (!pool) return true const cookies = parseCookies(req.headers.cookie) const token = cookies.hermes_admin if (!token) { const isApiRoute = req.url.startsWith("/api/") if (isApiRoute) { sendJson(res, 401, { error: "Authentication required" }) } else { res.writeHead(302, { Location: "/login" }) res.end() } return false } const hash = hashSecret(token) const session = await validateAdminSession(pool, hash) if (!session) { const isApiRoute = req.url.startsWith("/api/") if (isApiRoute) { sendJson(res, 401, { error: "Session expired or invalid" }) } else { res.writeHead(302, { Location: "/login" }) res.end() } return false } return true } async function h_adminLogin(req, res) { const body = await readJsonBody(req).catch(() => ({})) const { username, password } = body if (!pool || !ADMIN_USERNAME) { sendJson(res, 200, { ok: true }) // auth not configured, always succeed return } if (!adminCredentialsMatch(String(username || ""), String(password || ""), { username: ADMIN_USERNAME, password: ADMIN_PASSWORD })) { sendJson(res, 401, { error: "Invalid credentials" }) return } const { plaintext, hash } = createSessionToken() const expiresAt = new Date(Date.now() + SESSION_TTL_SECONDS * 1000) await createAdminSession(pool, hash, expiresAt) sendJson(res, 200, { ok: true }, { "Set-Cookie": serializeAdminCookie(plaintext, SESSION_TTL_SECONDS, { secure: ADMIN_COOKIE_SECURE }) }) } async function h_adminLogout(req, res) { if (pool) { const cookies = parseCookies(req.headers.cookie) const token = cookies.hermes_admin if (token) { const hash = hashSecret(token) await revokeAdminSession(pool, hash).catch(() => {}) } } sendJson(res, 200, { ok: true }, { "Set-Cookie": clearAdminCookie({ secure: ADMIN_COOKIE_SECURE }) }) } function h_health(_req, res) { sendJson(res, 200, { ok: true }) } // ─── API user management ────────────────────────────────────────────────────── async function handleApiUsersRoute(req, res, id, action) { const method = req.method // GET /api/admin/api-users → list if (method === "GET" && !id) { try { const users = await listApiUsers(pool) return sendJson(res, 200, { users }) } catch (err) { return sendJson(res, 500, { error: err.message }) } } // POST /api/admin/api-users → create if (method === "POST" && !id) { const body = await readJsonBody(req).catch(() => ({})) try { const result = await createApiUser(pool, { displayName: body.displayName, allowPre: body.allowPre, allowPost: body.allowPost, requestsPerMinute: body.requestsPerMinute, monthlyTokenLimit: body.monthlyTokenLimit, expiresAt: body.expiresAt, }) return sendJson(res, 200, result) } catch (err) { return sendJson(res, err.status === 400 ? 400 : 500, { error: err.message }) } } if (!id) return sendJson(res, 404, { error: "not found" }) // GET /api/admin/api-users/:id → single user if (method === "GET" && !action) { try { const users = await listApiUsers(pool) const user = users.find((u) => u.id === id) if (!user) return sendJson(res, 404, { error: "API user not found" }) return sendJson(res, 200, user) } catch (err) { return sendJson(res, 500, { error: err.message }) } } // PATCH /api/admin/api-users/:id → update if (method === "PATCH" && !action) { const body = await readJsonBody(req).catch(() => ({})) try { const user = await updateApiUser(pool, id, { displayName: body.displayName, allowPre: body.allowPre, allowPost: body.allowPost, requestsPerMinute: body.requestsPerMinute, monthlyTokenLimit: body.monthlyTokenLimit, expiresAt: body.expiresAt, }) return sendJson(res, 200, { user }) } catch (err) { return sendJson(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 500, { error: err.message }) } } // POST /api/admin/api-users/:id/rotate → rotate key if (method === "POST" && action === "rotate") { try { const result = await rotateApiUserKey(pool, id) return sendJson(res, 200, result) } catch (err) { return sendJson(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 500, { error: err.message }) } } // POST /api/admin/api-users/:id/revoke → revoke user if (method === "POST" && action === "revoke") { try { await revokeApiUser(pool, id) return sendJson(res, 200, { ok: true }) } catch (err) { return sendJson(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 500, { error: err.message }) } } // POST /api/admin/api-users/:id/reactivate → reactivate user if (method === "POST" && action === "reactivate") { try { const result = await reactivateApiUser(pool, id) return sendJson(res, 200, result) } catch (err) { return sendJson(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 500, { error: err.message }) } } // DELETE /api/admin/api-users/:id → soft delete if (method === "DELETE" && !action) { try { await deleteApiUser(pool, id) return sendJson(res, 200, { ok: true }) } catch (err) { return sendJson(res, err.status === 404 ? 404 : err.status === 400 ? 400 : 500, { error: err.message }) } } return sendJson(res, 404, { error: "not found" }) } // ─── Admin log download ──────────────────────────────────────────────────── async function h_adminLogsDownload(req, res) { if (!pool) { sendJson(res, 503, { error: "Auth not configured" }) return } const url = new URL(req.url, `http://${HOST}`) const apiUserId = url.searchParams.get("api_user_id") || null const startRaw = url.searchParams.get("start") || null const endRaw = url.searchParams.get("end") || null let start = null let end = null if (startRaw !== null) { const ts = new Date(startRaw).getTime() if (isNaN(ts)) { sendJson(res, 400, { error: "Invalid 'start' date" }) return } start = new Date(ts) } if (endRaw !== null) { const ts = new Date(endRaw).getTime() if (isNaN(ts)) { sendJson(res, 400, { error: "Invalid 'end' date" }) return } end = new Date(ts) } if (start !== null && end !== null && start >= end) { sendJson(res, 400, { error: "'start' must be before 'end'" }) return } const todayUtc = new Date().toISOString().slice(0, 10) res.writeHead(200, { "Content-Type": "application/x-ndjson", "Content-Disposition": `attachment; filename="hermes-audit-${todayUtc}.jsonl"`, "Cache-Control": "no-store", }) try { await streamJsonlLogs(pool, { apiUserId, start, end }, res) } catch (err) { // Headers already sent — just destroy the socket console.error("streamJsonlLogs error:", err) res.destroy(err) } } // ─── Routes ─────────────────────────────────────────────────────────────── const ROUTES = { "GET /api/status": h_status, "GET /api/paths": h_paths, // auth "POST /api/auth/add-oauth": h_addOauth, "GET /api/auth/progress": h_addProgress, "POST /api/auth/cancel-oauth": h_cancelOauth, "POST /api/auth/submit-input": h_submitOauthInput, "POST /api/auth/loopback-callback": h_submitOauthInput, "POST /api/auth/add-api-key": h_addApiKey, "POST /api/auth/remove": h_authRemove, "POST /api/auth/reset": h_authReset, "POST /api/auth/deepseek": h_setDeepseek, // routing "GET /api/fallback": h_fallbackList, "POST /api/fallback/set": h_fallbackSet, "POST /api/model/set": h_modelSet, // tools "GET /api/tools": h_tools, "POST /api/tools/toggle": h_toolToggle, // skills "GET /api/skills": h_skillsList, "POST /api/skills/action": h_skillToggle, // mcp "GET /api/mcp": h_mcpList, "POST /api/mcp/add": h_mcpAdd, "POST /api/mcp/test": h_mcpTest, "POST /api/mcp/remove": h_mcpRemove, // cron "GET /api/cron": h_cronList, "POST /api/cron/action": h_cronAction, // sessions "GET /api/sessions": h_sessionsList, "POST /api/sessions/prune": h_sessionsPrune, // hooks "GET /api/hooks": h_hooks, "GET /api/hooks/doctor": h_hooksDoctor, "POST /api/hooks/test": h_hooksTest, "POST /api/hooks/revoke": h_hooksRevoke, // skills (rich) "GET /api/skills/rich": h_skillsRich, "GET /api/skills/discover": h_skillsDiscover, "POST /api/skills/install": h_skillInstall, // plugins (rich) "GET /api/plugins/rich": h_pluginsRich, "GET /api/plugins/discover": h_pluginsDiscover, "GET /api/plugins": h_plugins, "POST /api/plugins/install": h_pluginInstall, "POST /api/plugins/action": h_pluginAction, // bundles (rich) "GET /api/bundles/rich": h_bundlesRich, "POST /api/bundles/create": h_bundleCreate, "POST /api/bundles/delete": h_bundleDelete, // curator (rich) "GET /api/curator/rich": h_curatorRich, "POST /api/curator/action": h_curatorAction, // memory "GET /api/memory": h_memory, "POST /api/memory/save": h_memorySave, "POST /api/memory/off": h_memoryOff, // kanban "GET /api/kanban": h_kanban, "POST /api/kanban/create": h_kanbanCreate, // webhooks "GET /api/webhooks": h_webhooks, "POST /api/webhooks/add": h_webhookAdd, "POST /api/webhooks/remove": h_webhookRemove, "POST /api/webhooks/test": h_webhookTest, // profiles / pairing "GET /api/profiles": h_profiles, "POST /api/profiles/action": h_profileAction, "POST /api/pairing/action": h_pairingAction, // config "GET /api/config/show": h_configShow, "GET /api/config/check": h_configCheck, "POST /api/config/migrate": h_configMigrate, "POST /api/config/set": h_configSet, // security "GET /api/security": h_security, // storage "POST /api/backup": h_backup, "GET /api/checkpoints": h_checkpoints, "POST /api/checkpoints/action": h_checkpointsAction, // insights "GET /api/insights": h_insights, // system extras "GET /api/dump": h_dump, "GET /api/prompt-size": h_promptSize, "GET /api/version": h_version, "POST /api/update": h_update, "GET /api/portal": h_portal, // system "GET /api/doctor": h_doctor, "GET /api/system": h_doSystemStatus, "GET /api/logs": h_logs, // admin audit log download "GET /api/admin/logs/download": h_adminLogsDownload, } function serveStatic(req, res) { let rel = req.url.split("?")[0] if (rel === "/") rel = "/index.html" if (rel === "/login") rel = "/login.html" const file = path.join(STATIC_DIR, rel.replace(/^\//, "")) if (!file.startsWith(STATIC_DIR)) return send(res, 403, "forbidden", "text/plain") if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return send(res, 404, "not found", "text/plain") const ext = path.extname(file).toLowerCase() const types = { ".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".svg": "image/svg+xml" } send(res, 200, fs.readFileSync(file), types[ext] || "application/octet-stream") } // Routes that are always public (bypass auth guard) const PUBLIC_ROUTES = new Set([ "GET /health", "GET /login", "GET /login.js", "GET /login.css", "POST /api/admin/login" ]) const server = http.createServer(async (req, res) => { try { const key = `${req.method} ${req.url.split("?")[0]}` // Always-public routes if (key === "GET /health") return h_health(req, res) if (key === "POST /api/admin/login") return await h_adminLogin(req, res) if (key === "GET /login") return serveStatic(req, res) if (key === "GET /login.js") return serveStatic(req, res) if (key === "GET /login.css") return serveStatic(req, res) // Logout is accessible when authenticated (or when auth is off) if (key === "POST /api/admin/logout") { if (!(await requireAdmin(req, res))) return return await h_adminLogout(req, res) } // API user management routes (dynamic :id routing — must come before ROUTES lookup) const apiUsersMatch = req.url.split("?")[0].match(/^\/api\/admin\/api-users(?:\/([^/?]+))?(?:\/([^/?]+))?$/) if (apiUsersMatch) { const [, id, action] = apiUsersMatch if (!(await requireAdmin(req, res))) return return await handleApiUsersRoute(req, res, id, action) } // All other routes require auth if (!(await requireAdmin(req, res))) return if (ROUTES[key]) return await ROUTES[key](req, res) if (req.method === "GET") return serveStatic(req, res) send(res, 404, { error: "not found" }) } catch (err) { console.error(err) send(res, 500, { error: err.message }) } }) async function main() { if (DATABASE_URL) { required("HERMES_ADMIN_USERNAME") const adminPw = required("HERMES_ADMIN_PASSWORD") if (adminPw.length < 16) throw new Error("HERMES_ADMIN_PASSWORD must be at least 16 characters") pool = createPool(DATABASE_URL) await runMigrations(pool) // Run one cleanup immediately cleanupExpiredMessageLogs(pool).catch((err) => console.error("audit cleanup failed:", err)) // Schedule cleanup every 6 hours const cleanupTimer = setInterval(() => { cleanupExpiredMessageLogs(pool).catch((err) => console.error("audit cleanup failed:", err)) }, 6 * 60 * 60 * 1000) cleanupTimer.unref() } await new Promise((resolve) => { server.listen(PORT, HOST, () => { const exeOk = fs.existsSync(HERMES_EXE) console.log(`Hermes Control Plane v2 — http://${HOST}:${PORT}/`) console.log(` hermes : ${HERMES_EXE} ${exeOk ? "✓" : "(MISSING)"}`) console.log(` config : ${CONFIG_FILE}`) console.log(` env : ${ENV_FILE}`) if (pool) { console.log(` auth : enabled (database configured)`) } resolve() }) }) } main().catch((err) => { console.error("startup failed:", err.message) process.exit(1) })