Files
hermes-control-panel/server.cjs
T
ZachariahSharma 74e276af92 fix: bind published ports to loopback and fix runHermes hang
- Ports now bind to HERMES_PUBLISHED_BIND_IP (default 127.0.0.1) so
  NPM on the same host proxies to 127.0.0.1:7843/8645/8646 and direct
  LAN/internet access is blocked without firewall rules
- runHermes: settle promise immediately on timeout (SIGKILL) instead of
  waiting for close event — prevents hanging when hermes spawns children
  that keep stdout/stderr open after the parent is killed
- Add HERMES_ADMIN_COOKIE_SECURE env var to set Secure flag on admin
  session cookie when the admin UI is served over HTTPS
- Document NPM deployment shapes in README

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 21:01:10 -06:00

1777 lines
65 KiB
JavaScript

#!/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 { 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
const ENV_FILE = path.join(HERMES_HOME, ".env")
const CONFIG_FILE = path.join(HERMES_HOME, "config.yaml")
const STATIC_DIR = __dirname
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" },
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
}
// ─── 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 normalizeLabel(value) {
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim()
}
function readCodexCliIdentities() {
const codexDir = path.join(os.homedir(), ".codex")
let files
try {
files = fs.readdirSync(codexDir).filter(f => f.startsWith("auth") && f.endsWith(".json")).sort()
} catch {
return []
}
const identities = []
for (const file of files) {
const suffix = file.slice(4, -5) // "auth<Suffix>.json" → "Suffix"
const hints = suffix ? [suffix.toLowerCase()] : ["main", "default"]
const data = safeJson(path.join(codexDir, file))
const identity = identityFromToken(data?.tokens?.id_token, data?.tokens?.access_token)
if (identity) identities.push({ provider: "openai-codex", file, hints, ...identity })
}
return identities
}
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 enrichAuthPools(pools) {
const identities = [
...readCodexCliIdentities(),
...readHermesAuthIdentities(),
]
return 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,
}
})
}))
}
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" })
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_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" })
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 })
}
async function h_mcpAdd(req, res) {
const body = await readBody(req)
const name = String(body.name || "")
const url = String(body.url || "")
const command = String(body.command || "")
if (!name) return send(res, 400, { error: "name required" })
let args
if (url) {
args = ["mcp", "add", name, "--url", url]
} else if (command) {
const extra = Array.isArray(body.args) ? body.args : []
args = ["mcp", "add", name, "--command", command, ...(extra.length ? ["--args", ...extra] : [])]
} else {
return send(res, 400, { error: "url or command required" })
}
const r = await runHermes(args, 30000)
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 <root>/<category>/<skill>/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" })
// 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/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/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 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 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 handleApiUsersRoute(req, res, id, action)
}
// All other routes require auth
if (!(await requireAdmin(req, res))) return
if (ROUTES[key]) return 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)
})