fix: auth bypass, SPA serving, and provider improvements (#97)

* feat(chat): polish syntax highlighting and tool payload rendering (#94)

* [verified] feat(chat): polish syntax highlighting and tool payload rendering

* [verified] fix(chat): tighten large tool payload rendering

* docs: update data volume path in Docker docs

Align documentation with docker-compose.yml change:
hermes-web-ui-data -> hermes-web-ui, /app/dist/data -> /root/.hermes-web-ui

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: bundle server build and restructure service modules

- Add build-server.mjs script for standalone server compilation
- Add logger service with structured output
- Restructure auth, gateway-manager, hermes-cli, hermes services
- Update docker-compose volume mount path
- Update tsconfig and entry point for bundled server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: separate controllers from routes and centralize route registration

- Extract business logic from route handlers into controllers/
- Add centralized route registry in routes/index.ts with public/auth/protected layers
- Replace global auth whitelist with sequential middleware registration
- Extract shared helpers to services/config-helpers.ts
- Allow custom provider name to be user-editable in ProviderFormModal
- Deduplicate custom providers by poolKey instead of base_url in getAvailable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: auth bypass via path case, SPA serving, and provider improvements

- Fix auth bypass: path case-insensitive check for /api, /v1, /upload
- Fix SPA returning 401: skip auth for non-API paths (static files)
- Fix profile switch: use local loading state instead of shared store ref
- Auto-append /v1 to base_url when fetching models (frontend + backend)
- Guard .env writing to built-in providers only
- Add builtin field to provider presets, enable base_url input in form
- Print auth token to console on startup (pino only writes to file)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Zhicheng Han <43314240+hanzckernel@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-21 12:35:48 +08:00
committed by GitHub
parent 21296a416b
commit 477af66232
65 changed files with 2743 additions and 2621 deletions
+54
View File
@@ -0,0 +1,54 @@
import * as hermesCli from '../services/hermes/hermes-cli'
import { getGatewayManagerInstance } from '../services/gateway-bootstrap'
import { config } from '../config'
declare const __APP_VERSION__: string
const LOCAL_VERSION = typeof __APP_VERSION__ !== 'undefined'
? __APP_VERSION__
: (() => { try { const { readFileSync } = require('fs'); const { resolve } = require('path'); return JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')).version } catch { return '0.0.0' } })()
let cachedLatestVersion = ''
export async function checkLatestVersion(): Promise<void> {
try {
const { readFileSync } = require('fs')
const pkg = JSON.parse(readFileSync(resolve(require('path').join(__dirname, '../../package.json')), 'utf-8'))
const name = pkg.name
const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(10000) })
if (res.ok) {
const data = await res.json() as { version: string }
cachedLatestVersion = data.version
if (cachedLatestVersion !== LOCAL_VERSION) {
console.log(`Update available: ${LOCAL_VERSION}${cachedLatestVersion}`)
}
}
} catch { /* ignore */ }
}
export function startVersionCheck(): void {
setTimeout(checkLatestVersion, 5000)
setInterval(checkLatestVersion, 30 * 60 * 1000)
}
export async function healthCheck(ctx: any) {
const raw = await hermesCli.getVersion()
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
let gatewayOk = false
try {
const mgr = getGatewayManagerInstance()
const upstream = mgr?.getUpstream() || config.upstream
const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000) })
gatewayOk = res.ok
} catch { }
ctx.body = {
status: gatewayOk ? 'ok' : 'error',
platform: 'hermes-agent',
version: hermesVersion,
gateway: gatewayOk ? 'running' : 'stopped',
webui_version: LOCAL_VERSION,
webui_latest: cachedLatestVersion,
webui_update_available: cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION,
}
}
function resolve(p: string) { return p }
@@ -0,0 +1,176 @@
import { randomUUID } from 'crypto'
import { join } from 'path'
import { homedir } from 'os'
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { logger } from '../../services/logger'
// --- OAuth Constants ---
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
const CODEX_DEVICE_AUTH_URL = 'https://auth.openai.com/api/accounts/deviceauth/usercode'
const CODEX_DEVICE_TOKEN_URL = 'https://auth.openai.com/api/accounts/deviceauth/token'
const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token'
const CODEX_DEFAULT_BASE_URL = 'https://chatgpt.com/backend-api/codex'
const CODEX_REDIRECT_URI = 'https://auth.openai.com/deviceauth/callback'
const CODEX_VERIFICATION_URL = 'https://auth.openai.com/codex/device'
const CODEX_HOME = join(homedir(), '.codex')
const POLL_MAX_DURATION = 15 * 60 * 1000
const POLL_DEFAULT_INTERVAL = 5000
// --- Session Store ---
interface CodexSession {
id: string; userCode: string; deviceAuthId: string
status: 'pending' | 'approved' | 'expired' | 'error'
error?: string; accessToken?: string; refreshToken?: string; createdAt: number
}
const sessions = new Map<string, CodexSession>()
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => { if (now - session.createdAt > POLL_MAX_DURATION + 60000) { sessions.delete(id) } })
}
// --- Auth file helpers ---
interface AuthJson { version?: number; active_provider?: string; providers?: Record<string, any>; credential_pool?: Record<string, any[]>; updated_at?: string }
function loadAuthJson(authPath: string): AuthJson {
try { return JSON.parse(readFileSync(authPath, 'utf-8')) as AuthJson } catch { return { version: 1 } }
}
function saveAuthJson(authPath: string, data: AuthJson): void {
data.updated_at = new Date().toISOString()
const dir = authPath.substring(0, authPath.lastIndexOf('/'))
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(authPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 })
}
function saveCodexCliTokens(accessToken: string, refreshToken: string): void {
const codexHome = process.env.CODEX_HOME || CODEX_HOME
const codexAuthPath = join(codexHome, 'auth.json')
const dir = codexAuthPath.substring(0, codexAuthPath.lastIndexOf('/'))
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
writeFileSync(codexAuthPath, JSON.stringify({ tokens: { access_token: accessToken, refresh_token: refreshToken }, last_refresh: new Date().toISOString() }, null, 2) + '\n', { mode: 0o600 })
}
function decodeJwtExp(token: string): number | null {
try {
const parts = token.split('.')
if (parts.length !== 3) return null
const payload = Buffer.from(parts[1], 'base64url').toString('utf-8')
const claims = JSON.parse(payload)
return typeof claims.exp === 'number' ? claims.exp : null
} catch { return null }
}
// --- Background login worker ---
async function codexLoginWorker(session: CodexSession, authPath: string): Promise<void> {
const startTime = Date.now()
const interval = POLL_DEFAULT_INTERVAL
while (Date.now() - startTime < POLL_MAX_DURATION) {
await new Promise(resolve => setTimeout(resolve, interval))
if (session.status !== 'pending') return
try {
const pollRes = await fetch(CODEX_DEVICE_TOKEN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_auth_id: session.deviceAuthId, user_code: session.userCode }),
signal: AbortSignal.timeout(10000),
})
if (pollRes.status === 200) {
const pollData = await pollRes.json() as { authorization_code: string; code_verifier: string }
const tokenRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code: pollData.authorization_code, redirect_uri: CODEX_REDIRECT_URI, client_id: CODEX_CLIENT_ID, code_verifier: pollData.code_verifier }).toString(),
signal: AbortSignal.timeout(15000),
})
if (!tokenRes.ok) { const errText = await tokenRes.text(); logger.error('Token exchange failed: %d %s', tokenRes.status, errText); session.status = 'error'; session.error = `Token exchange failed: ${tokenRes.status}`; return }
const tokenData = await tokenRes.json() as { access_token: string; refresh_token?: string }
const refreshToken = tokenData.refresh_token || ''
session.accessToken = tokenData.access_token; session.refreshToken = refreshToken; session.status = 'approved'
const auth = loadAuthJson(authPath)
if (!auth.providers) auth.providers = {}
auth.providers['openai-codex'] = { tokens: { access_token: tokenData.access_token, refresh_token: refreshToken }, last_refresh: new Date().toISOString(), auth_mode: 'chatgpt' }
if (!auth.credential_pool) auth.credential_pool = {}
auth.credential_pool['openai-codex'] = [{ id: `openai-codex-${Date.now()}`, label: 'OpenAI Codex', base_url: CODEX_DEFAULT_BASE_URL, access_token: tokenData.access_token, last_status: null }]
saveAuthJson(authPath, auth)
saveCodexCliTokens(tokenData.access_token, refreshToken)
logger.info('Login successful')
return
}
if (pollRes.status === 403 || pollRes.status === 404) { continue }
logger.error('Poll failed: %d', pollRes.status); session.status = 'error'; session.error = `Poll failed: ${pollRes.status}`; return
} catch (err: any) {
if (err.name === 'TimeoutError' || err.name === 'AbortError') { continue }
logger.error(err, 'Poll error'); session.status = 'error'; session.error = err.message; return
}
}
session.status = 'expired'
}
// --- Controller functions ---
export async function start(ctx: any) {
try {
cleanupExpiredSessions()
const res = await fetch(CODEX_DEVICE_AUTH_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'User-Agent': 'node-fetch' },
body: JSON.stringify({ client_id: CODEX_CLIENT_ID }), signal: AbortSignal.timeout(10000),
})
if (!res.ok) {
let errorBody: any = null; try { errorBody = await res.json() } catch { }
logger.error('Device code request failed: %d %s', res.status, errorBody)
let errorMessage = `Device code request failed: ${res.status}`
if (errorBody?.error?.code === 'unsupported_country_region_territory') { errorMessage = 'OpenAI does not support your region. You may need to use a proxy or VPN to access Codex.' }
ctx.status = 502; ctx.body = { error: errorMessage, code: errorBody?.error?.code }; return
}
const data = await res.json() as { user_code: string; device_auth_id: string; interval?: string }
const sessionId = randomUUID()
const session: CodexSession = { id: sessionId, userCode: data.user_code, deviceAuthId: data.device_auth_id, status: 'pending', createdAt: Date.now() }
sessions.set(sessionId, session)
const authPath = getActiveAuthPath()
codexLoginWorker(session, authPath).catch(err => { logger.error(err, 'Worker error'); session.status = 'error'; session.error = err.message })
ctx.body = { session_id: sessionId, user_code: data.user_code, verification_url: CODEX_VERIFICATION_URL, expires_in: 900 }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function poll(ctx: any) {
const session = sessions.get(ctx.params.sessionId)
if (!session) { ctx.status = 404; ctx.body = { error: 'Session not found' }; return }
ctx.body = { status: session.status, error: session.error || null }
}
export async function status(ctx: any) {
try {
const authPath = getActiveAuthPath()
const auth = loadAuthJson(authPath)
const tokens = auth.providers?.['openai-codex']?.tokens
if (!tokens?.access_token || !auth.providers) { ctx.body = { authenticated: false }; return }
const codexProvider = auth.providers['openai-codex']!
const exp = decodeJwtExp(tokens.access_token)
if (exp && exp <= Date.now() / 1000 + 120) {
if (tokens.refresh_token) {
try {
const refreshRes = await fetch(CODEX_OAUTH_TOKEN_URL, {
method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: CODEX_CLIENT_ID }).toString(),
signal: AbortSignal.timeout(15000),
})
if (refreshRes.ok) {
const newTokens = await refreshRes.json() as { access_token: string; refresh_token?: string }
codexProvider.tokens.access_token = newTokens.access_token
if (newTokens.refresh_token) { codexProvider.tokens.refresh_token = newTokens.refresh_token }
codexProvider.last_refresh = new Date().toISOString()
saveAuthJson(authPath, auth)
saveCodexCliTokens(newTokens.access_token, newTokens.refresh_token || tokens.refresh_token)
if (auth.credential_pool?.['openai-codex']?.[0]) { auth.credential_pool['openai-codex'][0].access_token = newTokens.access_token; saveAuthJson(authPath, auth) }
ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh }; return
}
} catch { }
}
ctx.body = { authenticated: false }; return
}
ctx.body = { authenticated: true, last_refresh: codexProvider.last_refresh }
} catch { ctx.body = { authenticated: false } }
}
@@ -0,0 +1,191 @@
import { readFile, writeFile, copyFile } from 'fs/promises'
import YAML from 'js-yaml'
import { restartGateway } from '../../services/hermes/hermes-cli'
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
import { saveEnvValue } from '../../services/config-helpers'
const PLATFORM_SECTIONS = new Set([
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
'weixin', 'wecom', 'feishu', 'dingtalk',
])
const configPath = () => getActiveConfigPath()
const envPath = () => getActiveEnvPath()
const envPlatformMap: Record<string, [string, string]> = {
TELEGRAM_BOT_TOKEN: ['telegram', 'token'],
DISCORD_BOT_TOKEN: ['discord', 'token'],
SLACK_BOT_TOKEN: ['slack', 'token'],
MATRIX_ACCESS_TOKEN: ['matrix', 'token'],
MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'],
FEISHU_APP_ID: ['feishu', 'extra.app_id'],
FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'],
DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'],
DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'],
DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
WECOM_BOT_ID: ['wecom', 'extra.bot_id'],
WECOM_SECRET: ['wecom', 'extra.secret'],
WEIXIN_TOKEN: ['weixin', 'token'],
WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'],
WEIXIN_BASE_URL: ['weixin', 'extra.base_url'],
WHATSAPP_ENABLED: ['whatsapp', 'enabled'],
}
const platformEnvMap: Record<string, Record<string, string>> = {}
for (const [envVar, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
platformEnvMap[platform][cfgPath] = envVar
}
function parseEnv(raw: string): Record<string, string> {
const env: Record<string, string> = {}
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
const val = trimmed.slice(eqIdx + 1).trim()
if (val) env[key] = val
}
return env
}
function setNested(obj: Record<string, any>, path: string, value: any) {
const parts = path.split('.')
let cur = obj
for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) cur[parts[i]] = {}; cur = cur[parts[i]] }
cur[parts[parts.length - 1]] = value
}
function deepMerge(target: Record<string, any>, source: Record<string, any>): Record<string, any> {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) &&
target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])) {
target[key] = deepMerge(target[key], source[key])
} else {
target[key] = source[key]
}
}
return target
}
async function readEnvPlatforms(): Promise<Record<string, any>> {
try {
const raw = await readFile(envPath(), 'utf-8')
const env = parseEnv(raw)
const platforms: Record<string, any> = {}
for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
const val = env[envKey]
if (val === undefined || val === '') continue
if (!platforms[platform]) platforms[platform] = {}
let finalVal: any = val
if (cfgPath === 'enabled') finalVal = val === 'true'
setNested(platforms[platform], cfgPath, finalVal)
}
return platforms
} catch { return {} }
}
async function readConfig(): Promise<Record<string, any>> {
const raw = await readFile(configPath(), 'utf-8')
return (YAML.load(raw) as Record<string, any>) || {}
}
async function writeConfig(data: Record<string, any>): Promise<void> {
const cp = configPath()
await copyFile(cp, cp + '.bak')
const yamlStr = YAML.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"', forceQuotes: false })
await writeFile(cp, yamlStr, 'utf-8')
}
export async function getConfig(ctx: any) {
try {
const config = await readConfig()
const envPlatforms = await readEnvPlatforms()
if (Object.keys(envPlatforms).length > 0) {
const existing = config.platforms || {}
for (const [platform, vals] of Object.entries(envPlatforms)) {
existing[platform] = { ...(existing[platform] || {}), ...(vals as Record<string, any>) }
}
config.platforms = existing
}
const { section, sections } = ctx.query
if (section) {
ctx.body = { [section as string]: config[section as string] || {} }
} else if (sections) {
const keys = (sections as string).split(',')
const result: Record<string, any> = {}
for (const key of keys) { result[key.trim()] = config[key.trim()] || {} }
ctx.body = result
} else {
ctx.body = config
}
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function updateConfig(ctx: any) {
const { section, values } = ctx.request.body as { section: string; values: Record<string, any> }
if (!section || !values) {
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
}
try {
const config = await readConfig()
config[section] = deepMerge(config[section] || {}, values)
await writeConfig(config)
if (PLATFORM_SECTIONS.has(section)) { await restartGateway() }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function updateCredentials(ctx: any) {
const { platform, values } = ctx.request.body as { platform: string; values: Record<string, any> }
if (!platform || !values) {
ctx.status = 400; ctx.body = { error: 'Missing platform or values' }; return
}
try {
const envMap = platformEnvMap[platform]
if (!envMap) {
ctx.status = 400; ctx.body = { error: `Unknown platform: ${platform}` }; return
}
const config = await readConfig()
let configChanged = false
const flatValues: Record<string, any> = {}
for (const [key, val] of Object.entries(values)) {
if (key === 'extra' && val && typeof val === 'object') {
for (const [subKey, subVal] of Object.entries(val as Record<string, any>)) { flatValues[`extra.${subKey}`] = subVal }
} else { flatValues[key] = val }
}
for (const [cfgPath, val] of Object.entries(flatValues)) {
const envVar = envMap[cfgPath]
if (!envVar) continue
if (val === undefined || val === null || val === '') {
await saveEnvValue(envVar, '')
const parts = cfgPath.split('.')
let obj: any = config.platforms?.[platform]
if (obj) {
if (parts.length === 1) { delete obj[parts[0]] }
else {
let cur = obj
for (let i = 0; i < parts.length - 1; i++) { if (!cur[parts[i]]) break; cur = cur[parts[i]] }
delete cur[parts[parts.length - 1]]
if (obj.extra && Object.keys(obj.extra).length === 0) delete obj.extra
}
if (Object.keys(obj).length === 0) { if (!config.platforms) config.platforms = {}; delete config.platforms[platform] }
configChanged = true
}
} else {
await saveEnvValue(envVar, String(val))
}
}
if (configChanged) { await writeConfig(config) }
await restartGateway()
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,33 @@
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
export async function list(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
const gateways = await mgr.listAll()
ctx.body = { gateways }
}
export async function start(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
try {
const status = await mgr.start(ctx.params.name)
ctx.body = { success: true, gateway: status }
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
}
export async function stop(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
try {
await mgr.stop(ctx.params.name)
ctx.body = { success: true }
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
}
export async function health(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
const status = await mgr.detectStatus(ctx.params.name)
ctx.body = { gateway: status }
}
@@ -0,0 +1,76 @@
import { existsSync, statSync } from 'fs'
import { readFile } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import * as hermesCli from '../../services/hermes/hermes-cli'
const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'logs', 'server.log')
interface LogEntry {
timestamp: string; level: string; logger: string; message: string; raw: string
}
function parseLine(line: string): LogEntry {
try {
const obj = JSON.parse(line)
if (obj.level && obj.time) {
const ts = new Date(obj.time).toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-')
const levelMap: Record<number, string> = { 10: 'DEBUG', 20: 'INFO', 30: 'WARN', 40: 'ERROR', 50: 'FATAL' }
return { timestamp: ts, level: levelMap[obj.level] || 'INFO', logger: obj.msg || '', message: typeof obj.msg === 'string' ? obj.msg : JSON.stringify(obj.msg), raw: line }
}
} catch {}
let match = line.match(/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s(.*)$/)
if (match) { return { timestamp: match[1], level: match[2], logger: match[3], message: match[4], raw: line } }
match = line.match(/^\[(\S+?)\]\s+\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\]\s+\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]\s(.*)$/)
if (match) { return { timestamp: match[2], level: match[3], logger: match[1], message: match[4], raw: line } }
return { timestamp: '', level: '', logger: '', message: line, raw: line }
}
export async function list(ctx: any) {
const files = await hermesCli.listLogFiles()
if (existsSync(WEBUI_LOG_FILE)) {
try {
const stat = statSync(WEBUI_LOG_FILE)
const size = stat.size > 1024 * 1024 ? `${(stat.size / 1024 / 1024).toFixed(1)}MB` : `${(stat.size / 1024).toFixed(1)}KB`
const modified = stat.mtime.toLocaleString()
files.push({ name: 'webui', size, modified })
} catch { }
}
ctx.body = { files }
}
export async function read(ctx: any) {
const logName = ctx.params.name
const lines = ctx.query.lines ? parseInt(ctx.query.lines as string, 10) : 100
const level = (ctx.query.level as string) || undefined
const session = (ctx.query.session as string) || undefined
const since = (ctx.query.since as string) || undefined
if (logName === 'webui') {
try {
if (!existsSync(WEBUI_LOG_FILE)) { ctx.body = { entries: [] }; return }
const content = await readFile(WEBUI_LOG_FILE, 'utf-8')
const rawLines = content.split('\n')
const sliced = rawLines.length > lines ? rawLines.slice(-lines) : rawLines
const entries: LogEntry[] = []
for (const line of sliced) { if (!line.trim()) continue; entries.push(parseLine(line)) }
ctx.body = { entries: entries.reverse() }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
return
}
try {
const content = await hermesCli.readLogs(logName, lines, level, session, since)
const rawLines = content.split('\n')
const entries: (LogEntry | null)[] = []
for (const line of rawLines) {
if (line.startsWith('---') || line.trim() === '') continue
entries.push(parseLine(line))
}
ctx.body = { entries: entries.reverse() }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,46 @@
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { safeReadFile, safeStat, getHermesDir } from '../../services/config-helpers'
export async function get(ctx: any) {
const hd = getHermesDir()
const memoryPath = join(hd, 'memories', 'MEMORY.md')
const userPath = join(hd, 'memories', 'USER.md')
const soulPath = join(hd, 'SOUL.md')
const [memory, user, soul, memoryStat, userStat, soulStat] = await Promise.all([
safeReadFile(memoryPath), safeReadFile(userPath), safeReadFile(soulPath),
safeStat(memoryPath), safeStat(userPath), safeStat(soulPath),
])
ctx.body = {
memory: memory || '', user: user || '', soul: soul || '',
memory_mtime: memoryStat?.mtime || null, user_mtime: userStat?.mtime || null, soul_mtime: soulStat?.mtime || null,
}
}
export async function save(ctx: any) {
const { section, content } = ctx.request.body as { section: string; content: string }
if (!section || !content) {
ctx.status = 400
ctx.body = { error: 'Missing section or content' }
return
}
if (section !== 'memory' && section !== 'user' && section !== 'soul') {
ctx.status = 400
ctx.body = { error: 'Section must be "memory", "user", or "soul"' }
return
}
let filePath: string
if (section === 'soul') {
filePath = join(getHermesDir(), 'SOUL.md')
} else {
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
filePath = join(getHermesDir(), 'memories', fileName)
}
try {
await writeFile(filePath, content, 'utf-8')
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,135 @@
import { readFile } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { readConfigYaml, writeConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
export async function getAvailable(ctx: any) {
try {
const config = await readConfigYaml()
const modelSection = config.model
let currentDefault = ''
let currentDefaultProvider = ''
if (typeof modelSection === 'object' && modelSection !== null) {
currentDefault = String(modelSection.default || '').trim()
currentDefaultProvider = String(modelSection.provider || '').trim()
} else if (typeof modelSection === 'string') {
currentDefault = modelSection.trim()
}
const groups: Array<{ provider: string; label: string; base_url: string; models: string[]; api_key: string }> = []
const seenProviders = new Set<string>()
let envContent = ''
try { envContent = await readFile(getActiveEnvPath(), 'utf-8') } catch { }
const envHasValue = (key: string): boolean => {
if (!key) return false
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
}
const envGetValue = (key: string): string => {
if (!key) return ''
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return match?.[1]?.trim() || ''
}
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string) => {
if (seenProviders.has(provider)) return
seenProviders.add(provider)
groups.push({ provider, label, base_url, models: [...models], api_key })
}
const isOAuthAuthorized = (providerKey: string): boolean => {
try {
const authPath = getActiveAuthPath()
if (!existsSync(authPath)) return false
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
return !!auth.providers?.[providerKey]?.tokens?.access_token
} catch { return false }
}
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
const label = preset?.label || providerKey.replace(/^custom:/, '')
const baseUrl = preset?.base_url || ''
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
if (catalogModels && catalogModels.length > 0) {
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
addGroup(providerKey, label, baseUrl, catalogModels, apiKey)
}
}
const customProviders = Array.isArray(config.custom_providers)
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
: []
const customFetches = await Promise.allSettled(
customProviders.map(async cp => {
if (!cp.base_url) return null
const providerKey = `custom:${cp.name.trim().toLowerCase().replace(/ /g, '-')}`
const baseUrl = cp.base_url.replace(/\/+$/, '')
let models = [cp.model]
if (cp.api_key) {
try { const fetched = await fetchProviderModels(baseUrl, cp.api_key); if (fetched.length > 0) models = fetched } catch { }
}
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
}),
)
for (const result of customFetches) {
if (result.status === 'fulfilled' && result.value) {
const { providerKey, label, base_url, models, api_key: cpApiKey } = result.value
addGroup(providerKey, label, base_url, models, cpApiKey)
}
}
for (const g of groups) { g.models = Array.from(new Set(g.models)) }
if (groups.length === 0) {
const fallback = buildModelGroups(config)
const allProviders = PROVIDER_PRESETS.map((p: any) => ({ provider: p.value, label: p.label, base_url: p.base_url, models: p.models }))
ctx.body = { ...fallback, allProviders }
return
}
const allProviders = PROVIDER_PRESETS.map((p: any) => ({ provider: p.value, label: p.label, base_url: p.base_url, models: p.models }))
ctx.body = { default: currentDefault, default_provider: currentDefaultProvider, groups, allProviders }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function getConfigModels(ctx: any) {
try {
const config = await readConfigYaml()
ctx.body = buildModelGroups(config)
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function setConfigModel(ctx: any) {
const { default: defaultModel, provider: reqProvider } = ctx.request.body as { default: string; provider?: string }
if (!defaultModel) {
ctx.status = 400
ctx.body = { error: 'Missing default model' }
return
}
try {
const config = await readConfigYaml()
if (typeof config.model !== 'object' || config.model === null) { config.model = {} }
config.model.default = defaultModel
if (reqProvider) { config.model.provider = reqProvider }
await writeConfigYaml(config)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,197 @@
import { createReadStream, existsSync, unlinkSync, writeFileSync } from 'fs'
import { mkdir, writeFile } from 'fs/promises'
import { basename, join } from 'path'
import { tmpdir } from 'os'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { logger } from '../../services/logger'
export async function list(ctx: any) {
try {
const profiles = await hermesCli.listProfiles()
ctx.body = { profiles }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function create(ctx: any) {
const { name, clone } = ctx.request.body as { name?: string; clone?: boolean }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
try {
const output = await hermesCli.createProfile(name, clone)
const mgr = getGatewayManagerInstance()
if (mgr) {
try { await mgr.start(name) } catch (err: any) {
logger.error(err, 'Failed to start gateway for profile "%s"', name)
}
}
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function get(ctx: any) {
try {
const profile = await hermesCli.getProfile(ctx.params.name)
ctx.body = { profile }
} catch (err: any) {
ctx.status = err.message.includes('not found') ? 404 : 500
ctx.body = { error: err.message }
}
}
export async function remove(ctx: any) {
const { name } = ctx.params
if (name === 'default') {
ctx.status = 400
ctx.body = { error: 'Cannot delete the default profile' }
return
}
try {
const mgr = getGatewayManagerInstance()
if (mgr) { try { await mgr.stop(name) } catch { } }
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to delete profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function rename(ctx: any) {
const { new_name } = ctx.request.body as { new_name?: string }
if (!new_name) {
ctx.status = 400
ctx.body = { error: 'Missing new_name' }
return
}
try {
const ok = await hermesCli.renameProfile(ctx.params.name, new_name)
if (ok) {
ctx.body = { success: true }
} else {
ctx.status = 500
ctx.body = { error: 'Failed to rename profile' }
}
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function switchProfile(ctx: any) {
const { name } = ctx.request.body as { name?: string }
if (!name) {
ctx.status = 400
ctx.body = { error: 'Missing profile name' }
return
}
try {
const output = await hermesCli.useProfile(name)
await new Promise(r => setTimeout(r, 1000))
const mgr = getGatewayManagerInstance()
if (mgr) { mgr.setActiveProfile(name) }
try {
const detail = await hermesCli.getProfile(name)
logger.debug('Profile detail.path = %s', detail.path)
if (!existsSync(join(detail.path, 'config.yaml'))) {
try { await hermesCli.setupReset() } catch { }
}
const profileEnv = join(detail.path, '.env')
if (!existsSync(profileEnv)) {
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
logger.info('Created .env for: %s', detail.path)
}
} catch (err: any) {
logger.error(err, 'Ensure config failed')
}
ctx.body = { success: true, message: output.trim() }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function exportProfile(ctx: any) {
const { name } = ctx.params
const outputPath = join(tmpdir(), `hermes-profile-${name}.tar.gz`)
try {
await hermesCli.exportProfile(name, outputPath)
if (!existsSync(outputPath)) {
ctx.status = 500
ctx.body = { error: 'Export file not found' }
return
}
const filename = basename(outputPath)
ctx.set('Content-Disposition', `attachment; filename="${filename}"`)
ctx.set('Content-Type', 'application/gzip')
ctx.body = createReadStream(outputPath)
ctx.res.on('finish', () => { try { unlinkSync(outputPath) } catch { } })
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function importProfile(ctx: any) {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400
ctx.body = { error: 'Expected multipart/form-data' }
return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400
ctx.body = { error: 'Missing boundary' }
return
}
const tmpDir = join(tmpdir(), 'hermes-import')
await mkdir(tmpDir, { recursive: true })
const chunks: Buffer[] = []
for await (const chunk of ctx.req) chunks.push(chunk)
const body = Buffer.concat(chunks).toString('latin1')
const parts = body.split(boundary).slice(1, -1)
let archivePath = ''
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n')
if (headerEnd === -1) continue
const header = part.substring(0, headerEnd)
const data = part.substring(headerEnd + 4, part.length - 2)
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
const filename = filenameMatch[1]
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue
archivePath = join(tmpDir, filename)
await writeFile(archivePath, Buffer.from(data, 'binary'))
break
}
if (!archivePath) {
ctx.status = 400
ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' }
return
}
try {
const result = await hermesCli.importProfile(archivePath)
try { unlinkSync(archivePath) } catch { }
ctx.body = { success: true, message: result.trim() }
} catch (err: any) {
try { unlinkSync(archivePath) } catch { }
ctx.status = 500
ctx.body = { error: err.message }
}
}
@@ -0,0 +1,139 @@
import { existsSync, readFileSync } from 'fs'
import { writeFile } from 'fs/promises'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { readConfigYaml, writeConfigYaml, saveEnvValue, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { logger } from '../../services/logger'
export async function create(ctx: any) {
const { name, base_url, api_key, model, providerKey } = ctx.request.body as {
name: string; base_url: string; api_key: string; model: string; providerKey?: string | null
}
if (!name || !base_url || !model) {
ctx.status = 400; ctx.body = { error: 'Missing name, base_url, or model' }; return
}
if (!api_key) {
ctx.status = 400; ctx.body = { error: 'Missing API key' }; return
}
try {
const poolKey = providerKey || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
const isBuiltin = poolKey in PROVIDER_ENV_MAP
if (!isBuiltin) {
const config = await readConfigYaml()
if (!Array.isArray(config.custom_providers)) { config.custom_providers = [] }
const existing = (config.custom_providers as any[]).find(
(e: any) => `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
)
if (existing) {
existing.base_url = base_url
existing.api_key = api_key
existing.model = model
} else {
config.custom_providers.push({ name, base_url, api_key, model })
}
await writeConfigYaml(config)
}
const envMapping = isBuiltin ? (PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || '']) : null
if (envMapping) {
await saveEnvValue(envMapping.api_key_env, api_key)
if (envMapping.base_url_env) { await saveEnvValue(envMapping.base_url_env, base_url) }
}
const config2 = await readConfigYaml()
if (typeof config2.model !== 'object' || config2.model === null) { config2.model = {} }
config2.model.default = model
config2.model.provider = poolKey
await writeConfigYaml(config2)
try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function update(ctx: any) {
const poolKey = decodeURIComponent(ctx.params.poolKey)
const { name, base_url, api_key, model } = ctx.request.body as {
name?: string; base_url?: string; api_key?: string; model?: string
}
try {
const isCustom = poolKey.startsWith('custom:')
if (isCustom) {
const config = await readConfigYaml()
if (!Array.isArray(config.custom_providers)) {
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
}
const entry = (config.custom_providers as any[]).find((e: any) => {
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
})
if (!entry) {
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
}
if (name !== undefined) entry.name = name
if (base_url !== undefined) entry.base_url = base_url
if (api_key !== undefined) entry.api_key = api_key
if (model !== undefined) entry.model = model
await writeConfigYaml(config)
} else {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (!envMapping?.api_key_env) {
ctx.status = 400; ctx.body = { error: `Cannot update credentials for "${poolKey}"` }; return
}
if (api_key !== undefined) { await saveEnvValue(envMapping.api_key_env, api_key) }
}
try { await hermesCli.restartGateway() } catch (e: any) { logger.error(e, 'Gateway restart failed') }
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
export async function remove(ctx: any) {
const poolKey = decodeURIComponent(ctx.params.poolKey)
try {
const config = await readConfigYaml()
const isCustom = poolKey.startsWith('custom:')
if (isCustom) {
const idx = Array.isArray(config.custom_providers)
? (config.custom_providers as any[]).findIndex((e: any) => {
return `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}` === poolKey
})
: -1
if (idx === -1) {
ctx.status = 404; ctx.body = { error: `Custom provider "${poolKey}" not found` }; return
}
(config.custom_providers as any[]).splice(idx, 1)
await writeConfigYaml(config)
} else {
const envMapping = PROVIDER_ENV_MAP[poolKey]
if (envMapping?.api_key_env) {
await saveEnvValue(envMapping.api_key_env, '')
} else if (!envMapping?.api_key_env) {
try {
const authPath = getActiveAuthPath()
if (existsSync(authPath)) {
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
if (auth.providers?.[poolKey]) { delete auth.providers[poolKey] }
if (auth.credential_pool?.[poolKey]) { delete auth.credential_pool[poolKey] }
await writeFile(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
}
} catch (err: any) { logger.error(err, 'Failed to clear OAuth tokens for %s', poolKey) }
}
}
const currentProvider = config.model?.provider
if (currentProvider === poolKey) {
const freshConfig = await readConfigYaml()
const remaining = Array.isArray(freshConfig.custom_providers) ? freshConfig.custom_providers as any[] : []
const fallbackCp = remaining[0]
if (fallbackCp) {
const fallbackKey = `custom:${fallbackCp.name.trim().toLowerCase().replace(/ /g, '-')}`
if (typeof freshConfig.model !== 'object' || freshConfig.model === null) { freshConfig.model = {} }
freshConfig.model.default = fallbackCp.model
freshConfig.model.provider = fallbackKey
await writeConfigYaml(freshConfig)
}
}
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
@@ -0,0 +1,55 @@
import * as hermesCli from '../../services/hermes/hermes-cli'
import { listSessionSummaries } from '../../services/hermes/sessions-db'
import { logger } from '../../services/logger'
export async function list(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
try {
const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000)
ctx.body = { sessions }
return
} catch (err) {
logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI')
}
const sessions = await hermesCli.listSessions(source, limit)
ctx.body = { sessions }
}
export async function get(ctx: any) {
const session = await hermesCli.getSession(ctx.params.id)
if (!session) {
ctx.status = 404
ctx.body = { error: 'Session not found' }
return
}
ctx.body = { session }
}
export async function remove(ctx: any) {
const ok = await hermesCli.deleteSession(ctx.params.id)
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
ctx.body = { ok: true }
}
export async function rename(ctx: any) {
const { title } = ctx.request.body as { title?: string }
if (!title || typeof title !== 'string') {
ctx.status = 400
ctx.body = { error: 'title is required' }
return
}
const ok = await hermesCli.renameSession(ctx.params.id, title.trim())
if (!ok) {
ctx.status = 500
ctx.body = { error: 'Failed to rename session' }
return
}
ctx.body = { ok: true }
}
@@ -0,0 +1,94 @@
import { readdir } from 'fs/promises'
import { join, resolve } from 'path'
import {
readConfigYaml, writeConfigYaml,
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
} from '../../services/config-helpers'
export async function list(ctx: any) {
const skillsDir = join(getHermesDir(), 'skills')
try {
const config = await readConfigYaml()
const disabledList: string[] = config.skills?.disabled || []
const entries = await readdir(skillsDir, { withFileTypes: true })
const categories: any[] = []
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const catDir = join(skillsDir, entry.name)
const catDesc = await safeReadFile(join(catDir, 'DESCRIPTION.md'))
const catDescription = catDesc ? catDesc.trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 100) : ''
const skillEntries = await readdir(catDir, { withFileTypes: true })
const skills: any[] = []
for (const se of skillEntries) {
if (!se.isDirectory()) continue
const skillMd = await safeReadFile(join(catDir, se.name, 'SKILL.md'))
if (skillMd) {
skills.push({ name: se.name, description: extractDescription(skillMd), enabled: !disabledList.includes(se.name) })
}
}
if (skills.length > 0) {
categories.push({ name: entry.name, description: catDescription, skills })
}
}
categories.sort((a, b) => a.name.localeCompare(b.name))
for (const cat of categories) { cat.skills.sort((a: any, b: any) => a.name.localeCompare(b.name)) }
ctx.body = { categories }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
}
}
export async function toggle(ctx: any) {
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
if (!name || typeof enabled !== 'boolean') {
ctx.status = 400
ctx.body = { error: 'Missing name or enabled flag' }
return
}
try {
const config = await readConfigYaml()
if (!config.skills) config.skills = {}
if (!Array.isArray(config.skills.disabled)) config.skills.disabled = []
const disabled = config.skills.disabled as string[]
const idx = disabled.indexOf(name)
if (enabled) { if (idx !== -1) disabled.splice(idx, 1) }
else { if (idx === -1) disabled.push(name) }
await writeConfigYaml(config)
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function listFiles(ctx: any) {
const { category, skill } = ctx.params
const skillDir = join(getHermesDir(), 'skills', category, skill)
try {
const allFiles = await listFilesRecursive(skillDir, '')
const files = allFiles.filter(f => f.path !== 'SKILL.md')
ctx.body = { files }
} catch (err: any) {
ctx.status = 500
ctx.body = { error: err.message }
}
}
export async function readFile_(ctx: any) {
const filePath = (ctx.params as any).path
const hd = getHermesDir()
const fullPath = resolve(join(hd, 'skills', filePath))
if (!fullPath.startsWith(join(hd, 'skills'))) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
}
const content = await safeReadFile(fullPath)
if (content === null) {
ctx.status = 404
ctx.body = { error: 'File not found' }
return
}
ctx.body = { content }
}
@@ -0,0 +1,68 @@
import axios from 'axios'
import { readFile, writeFile, chmod } from 'fs/promises'
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
import { restartGateway } from '../../services/hermes/hermes-cli'
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
const envPath = () => getActiveEnvPath()
export async function getQrcode(ctx: any) {
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, { params: { bot_type: 3 }, timeout: 15000 })
const data = res.data
if (!data || !data.qrcode) { ctx.status = 500; ctx.body = { error: 'Failed to get QR code' }; return }
ctx.body = { qrcode: data.qrcode, qrcode_url: data.qrcode_img_content }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message || 'Failed to connect to iLink API' }
}
}
export async function pollStatus(ctx: any) {
const qrcode = ctx.query.qrcode as string
if (!qrcode) { ctx.status = 400; ctx.body = { error: 'Missing qrcode parameter' }; return }
try {
const res = await axios.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, { params: { qrcode }, timeout: 35000 })
const data = res.data
const status = data?.status || 'wait'
if (status === 'confirmed') {
ctx.body = { status: 'confirmed', account_id: data.ilink_bot_id, token: data.bot_token, base_url: data.baseurl }
} else {
ctx.body = { status }
}
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message || 'Failed to poll QR status' }
}
}
export async function save(ctx: any) {
const { account_id, token, base_url } = ctx.request.body as { account_id: string; token: string; base_url?: string }
if (!account_id || !token) { ctx.status = 400; ctx.body = { error: 'Missing account_id or token' }; return }
try {
let raw: string
try { raw = await readFile(envPath(), 'utf-8') } catch { raw = '' }
const entries: Record<string, string> = { WEIXIN_ACCOUNT_ID: account_id, WEIXIN_TOKEN: token }
if (base_url) entries.WEIXIN_BASE_URL = base_url
const lines = raw.split('\n')
const existingKeys = new Set<string>()
const result: string[] = []
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('#')) { result.push(line); continue }
const eqIdx = trimmed.indexOf('=')
if (eqIdx !== -1) {
const key = trimmed.slice(0, eqIdx).trim()
if (key in entries) { result.push(`${key}=${entries[key]}`); existingKeys.add(key); continue }
}
result.push(line)
}
for (const [key, val] of Object.entries(entries)) { if (!existingKeys.has(key)) { result.push(`${key}=${val}`) } }
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
const ep = envPath()
await writeFile(ep, output, 'utf-8')
try { await chmod(ep, 0o600) } catch { }
await restartGateway()
ctx.body = { success: true }
} catch (err: any) {
ctx.status = 500; ctx.body = { error: err.message }
}
}
+19
View File
@@ -0,0 +1,19 @@
import { spawn } from 'child_process'
export async function handleUpdate(ctx: any) {
const isWin = process.platform === 'win32'
const cmd = isWin ? 'cmd /c npm install -g hermes-web-ui@latest' : 'npm install -g hermes-web-ui@latest'
try {
const { execSync } = await import('child_process')
const output = execSync(cmd, { encoding: 'utf-8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'] })
ctx.body = { success: true, message: output.trim() }
setTimeout(() => {
spawn(isWin ? 'cmd' : 'sh', isWin ? ['/c', 'hermes-web-ui restart'] : ['-c', 'hermes-web-ui restart'], {
detached: true, stdio: 'ignore', windowsHide: true,
}).unref()
process.exit(0)
}, 2000)
} catch (err: any) {
ctx.status = 500; ctx.body = { success: false, message: err.stderr || err.message }
}
}
+62
View File
@@ -0,0 +1,62 @@
import { randomBytes } from 'crypto'
import { writeFile } from 'fs/promises'
import { config } from '../config'
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
export async function handleUpload(ctx: any) {
const contentType = ctx.get('content-type') || ''
if (!contentType.startsWith('multipart/form-data')) {
ctx.status = 400; ctx.body = { error: 'Expected multipart/form-data' }; return
}
const boundary = '--' + contentType.split('boundary=')[1]
if (!boundary || boundary === '--undefined') {
ctx.status = 400; ctx.body = { error: 'Missing boundary' }; return
}
const chunks: Buffer[] = []
let totalSize = 0
for await (const chunk of ctx.req) {
totalSize += chunk.length
if (totalSize > MAX_UPLOAD_SIZE) {
ctx.status = 413; ctx.body = { error: `File too large (max ${MAX_UPLOAD_SIZE / 1024 / 1024}MB)` }; return
}
chunks.push(chunk)
}
const raw = Buffer.concat(chunks)
const boundaryBuf = Buffer.from(boundary)
const parts = splitMultipart(raw, boundaryBuf)
const results: { name: string; path: string }[] = []
for (const part of parts) {
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
if (headerEnd === -1) continue
const headerBuf = part.subarray(0, headerEnd)
const header = headerBuf.toString('utf-8')
const data = part.subarray(headerEnd + 4, part.length - 2)
let filename = ''
const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i)
if (filenameStarMatch) { filename = decodeURIComponent(filenameStarMatch[1]) }
else {
const filenameMatch = header.match(/filename="([^"]+)"/)
if (!filenameMatch) continue
filename = filenameMatch[1]
}
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
const savedName = randomBytes(8).toString('hex') + ext
const savedPath = `${config.uploadDir}/${savedName}`
await writeFile(savedPath, data)
results.push({ name: filename, path: savedPath })
}
ctx.body = { files: results }
}
function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
const parts: Buffer[] = []
let start = 0
while (true) {
const idx = raw.indexOf(boundary, start)
if (idx === -1) break
if (start > 0) { parts.push(raw.subarray(start + 2, idx)) }
start = idx + boundary.length
}
return parts
}
@@ -0,0 +1,14 @@
import { emitWebhook } from '../services/hermes/hermes'
import { logger } from '../services/logger'
export async function handleWebhook(ctx: any) {
const payload = ctx.request.body
if (!payload || !payload.event) {
ctx.status = 400
ctx.body = { error: 'Missing event field' }
return
}
logger.info('Received webhook event: %s', payload.event)
emitWebhook(payload)
ctx.body = { ok: true }
}