f473be4033
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.
2263 lines
83 KiB
JavaScript
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)
|
|
})
|