Files
ZachariahSharma f473be4033 feat: add authenticated URL-mode MCP servers from Control Panel
Add an optional auth-token (bearer) field and a custom-headers textarea to
the MCP 'Add server' pane. URL-mode servers are now registered by writing
hermes config directly via the venv's config helpers (save_env_value +
save_config) instead of shelling into the interactive, network-probing
'hermes mcp add --url' flow that hung/504'd on auth challenges.

- Token is stored in ~/.hermes/.env (mode 600) and referenced in config.yaml
  as 'Authorization: Bearer ${MCP_<NAME>_API_KEY}' — never plaintext config.
- Registration is non-blocking: no live probe, so a slow/unreachable server
  can't hang the request.
- Add a per-server 'Test' button (POST /api/mcp/test -> hermes mcp test).
- Contract tests for the new fields, payload, no-probe path, and test route.
2026-06-10 21:19:05 -06:00

2263 lines
83 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 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<Suffix>.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: <name> <url>. 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 <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" })
// 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)
})