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:
@@ -1,73 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
import { resolve } from 'path'
|
||||
import { readFileSync } from 'fs'
|
||||
import { getGatewayManager } from './hermes/gateways'
|
||||
import * as hermesCli from '../services/hermes/hermes-cli'
|
||||
import { config } from '../config'
|
||||
|
||||
function getLocalVersion(): string {
|
||||
const candidates = [
|
||||
resolve(__dirname, '../../../package.json'),
|
||||
resolve(__dirname, '../../../../package.json'),
|
||||
]
|
||||
for (const p of candidates) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(p, 'utf-8')).version
|
||||
} catch { }
|
||||
}
|
||||
return '0.0.0'
|
||||
}
|
||||
|
||||
const LOCAL_VERSION = getLocalVersion()
|
||||
let cachedLatestVersion = ''
|
||||
|
||||
export async function checkLatestVersion(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch('https://registry.npmjs.org/hermes-web-ui/latest', {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const latest = data.version || ''
|
||||
if (latest && latest !== cachedLatestVersion) {
|
||||
cachedLatestVersion = latest
|
||||
if (latest !== LOCAL_VERSION) {
|
||||
console.log(`⬆ New version available: v${LOCAL_VERSION} → v${latest}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
export function startVersionCheck(): void {
|
||||
checkLatestVersion()
|
||||
setInterval(checkLatestVersion, 60 * 60 * 1000)
|
||||
}
|
||||
import * as ctrl from '../controllers/health'
|
||||
|
||||
export const healthRoutes = new Router()
|
||||
|
||||
healthRoutes.get('/health', async (ctx) => {
|
||||
const raw = await hermesCli.getVersion()
|
||||
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
|
||||
healthRoutes.get('/health', ctrl.healthCheck)
|
||||
|
||||
let gatewayOk = false
|
||||
try {
|
||||
const mgr = getGatewayManager()
|
||||
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,
|
||||
}
|
||||
})
|
||||
export { startVersionCheck } from '../controllers/health'
|
||||
|
||||
@@ -1,347 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
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 * as ctrl from '../../controllers/hermes/codex-auth'
|
||||
|
||||
// --- 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 // 15 minutes
|
||||
const POLL_DEFAULT_INTERVAL = 5000 // 5 seconds
|
||||
|
||||
// --- 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 {
|
||||
const raw = readFileSync(authPath, 'utf-8')
|
||||
return JSON.parse(raw) 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 })
|
||||
const data = {
|
||||
tokens: { access_token: accessToken, refresh_token: refreshToken },
|
||||
last_refresh: new Date().toISOString(),
|
||||
}
|
||||
writeFileSync(codexAuthPath, JSON.stringify(data, 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 {
|
||||
// Step 3: Poll for authorization
|
||||
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 }
|
||||
|
||||
// Step 4: Exchange authorization code for tokens
|
||||
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()
|
||||
console.error('[Codex Auth] Token exchange failed:', 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'
|
||||
|
||||
// Save to auth.json
|
||||
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',
|
||||
}
|
||||
|
||||
// Add to credential pool
|
||||
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)
|
||||
|
||||
// Save to ~/.codex/auth.json for CLI sync
|
||||
saveCodexCliTokens(tokenData.access_token, refreshToken)
|
||||
|
||||
console.log('[Codex Auth] Login successful')
|
||||
return
|
||||
}
|
||||
|
||||
if (pollRes.status === 403 || pollRes.status === 404) {
|
||||
// Not yet authorized, keep polling
|
||||
continue
|
||||
}
|
||||
|
||||
// Other error status
|
||||
console.error('[Codex Auth] Poll failed:', pollRes.status)
|
||||
session.status = 'error'
|
||||
session.error = `Poll failed: ${pollRes.status}`
|
||||
return
|
||||
} catch (err: any) {
|
||||
if (err.name === 'TimeoutError' || err.name === 'AbortError') {
|
||||
continue
|
||||
}
|
||||
console.error('[Codex Auth] Poll error:', err.message)
|
||||
session.status = 'error'
|
||||
session.error = err.message
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout
|
||||
session.status = 'expired'
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
export const codexAuthRoutes = new Router()
|
||||
|
||||
codexAuthRoutes.post('/api/hermes/auth/codex/start', async (ctx) => {
|
||||
try {
|
||||
cleanupExpiredSessions()
|
||||
|
||||
// Step 1: Request device code
|
||||
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 { /* ignore */ }
|
||||
console.error(`[codex-auth] Device code request failed: ${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)
|
||||
|
||||
// Start background worker
|
||||
const authPath = getActiveAuthPath()
|
||||
codexLoginWorker(session, authPath).catch(err => {
|
||||
console.error('[Codex Auth] Worker error:', err)
|
||||
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, // 15 minutes
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', async (ctx) => {
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/status', async (ctx) => {
|
||||
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']!
|
||||
|
||||
// Check if token is expired
|
||||
const exp = decodeJwtExp(tokens.access_token)
|
||||
if (exp && exp <= Date.now() / 1000 + 120) {
|
||||
// Try refresh
|
||||
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)
|
||||
|
||||
// Update credential pool too
|
||||
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 {
|
||||
// Refresh failed
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = { authenticated: false }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
authenticated: true,
|
||||
last_refresh: codexProvider.last_refresh,
|
||||
}
|
||||
} catch {
|
||||
ctx.body = { authenticated: false }
|
||||
}
|
||||
})
|
||||
codexAuthRoutes.post('/api/hermes/auth/codex/start', ctrl.start)
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/poll/:sessionId', ctrl.poll)
|
||||
codexAuthRoutes.get('/api/hermes/auth/codex/status', ctrl.status)
|
||||
|
||||
@@ -1,330 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
import { readFile, writeFile, copyFile } from 'fs/promises'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import YAML from 'js-yaml'
|
||||
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileDir } from '../../services/hermes/hermes-profile'
|
||||
|
||||
// Platform sections that require gateway restart after config change
|
||||
const PLATFORM_SECTIONS = new Set([
|
||||
'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
|
||||
'weixin', 'wecom', 'feishu', 'dingtalk',
|
||||
])
|
||||
|
||||
const configPath = () => getActiveConfigPath()
|
||||
const envPath = () => getActiveEnvPath()
|
||||
|
||||
// Env var → (platform, configPath in PlatformConfig) mapping
|
||||
// Matches hermes _apply_env_overrides() in gateway/config.py
|
||||
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 has no _apply_env_overrides entry in hermes;
|
||||
// the adapter reads these env vars directly at runtime.
|
||||
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'],
|
||||
}
|
||||
|
||||
// Reverse map: (platform, configPath) → env var
|
||||
const platformEnvMap: Record<string, Record<string, string>> = {}
|
||||
for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) {
|
||||
if (!platformEnvMap[platform]) platformEnvMap[platform] = {}
|
||||
platformEnvMap[platform][configPath] = 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 getNested(obj: Record<string, any>, path: string): any {
|
||||
const parts = path.split('.')
|
||||
let cur = obj
|
||||
for (const p of parts) {
|
||||
if (!cur || typeof cur !== 'object') return undefined
|
||||
cur = cur[p]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
// Write a KEY=value to .env (matching hermes save_env_value behavior)
|
||||
// If value is empty, remove the line instead
|
||||
async function saveEnvValue(key: string, value: string): Promise<void> {
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(envPath(), 'utf-8')
|
||||
} catch {
|
||||
raw = ''
|
||||
}
|
||||
|
||||
const remove = !value
|
||||
const lines = raw.split('\n')
|
||||
let found = false
|
||||
const result: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#')) {
|
||||
// Check if there's a commented-out version of this key
|
||||
if (trimmed.startsWith(`# ${key}=`)) {
|
||||
if (!remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
} else {
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
|
||||
if (!remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found && !remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
|
||||
// Remove trailing empty lines, keep exactly one trailing newline
|
||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
await writeFile(envPath(), output, 'utf-8')
|
||||
// Set permissions to 0600 (owner only), matching hermes behavior
|
||||
try { await chmod(envPath(), 0o600) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
import * as ctrl from '../../controllers/hermes/config'
|
||||
|
||||
export const configRoutes = new Router()
|
||||
|
||||
// GET /api/config — read config sections
|
||||
configRoutes.get('/api/hermes/config', async (ctx) => {
|
||||
try {
|
||||
const config = await readConfig()
|
||||
// Merge .env platform credentials into platforms section
|
||||
const envPlatforms = await readEnvPlatforms()
|
||||
if (Object.keys(envPlatforms).length > 0) {
|
||||
// Deep-merge: env values fill in missing, don't overwrite config.yaml
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config — update a config section (writes to config.yaml)
|
||||
configRoutes.put('/api/hermes/config', async (ctx) => {
|
||||
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)
|
||||
// Restart gateway for platform/channel config changes
|
||||
if (PLATFORM_SECTIONS.has(section)) {
|
||||
await restartGateway()
|
||||
}
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/credentials — save platform credentials to .env
|
||||
// Body: { platform: string, values: Record<string, any> }
|
||||
// values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc.
|
||||
configRoutes.put('/api/hermes/config/credentials', async (ctx) => {
|
||||
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
|
||||
}
|
||||
|
||||
// Also clean up config.yaml platforms.<platform> to keep in sync
|
||||
const config = await readConfig()
|
||||
let configChanged = false
|
||||
|
||||
// Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' }
|
||||
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, '')
|
||||
// Remove from config.yaml too
|
||||
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]]
|
||||
// Clean up empty extra
|
||||
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)
|
||||
}
|
||||
|
||||
// Restart gateway for platform credential changes
|
||||
await restartGateway()
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
configRoutes.get('/api/hermes/config', ctrl.getConfig)
|
||||
configRoutes.put('/api/hermes/config', ctrl.updateConfig)
|
||||
configRoutes.put('/api/hermes/config/credentials', ctrl.updateCredentials)
|
||||
|
||||
@@ -1,817 +0,0 @@
|
||||
import Router from '@koa/router'
|
||||
import { readdir, readFile, stat, writeFile, mkdir, copyFile } from 'fs/promises'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { join, resolve } from 'path'
|
||||
import YAML from 'js-yaml'
|
||||
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
|
||||
// --- Provider env var mapping (from hermes providers.py HERMES_OVERLAYS + config.py) ---
|
||||
// Maps provider key → { api_key_envs: all env var aliases for API key, base_url_env: env var for base URL }
|
||||
const PROVIDER_ENV_MAP: Record<string, { api_key_env: string; base_url_env: string }> = {
|
||||
openrouter: { api_key_env: 'OPENROUTER_API_KEY', base_url_env: '' },
|
||||
zai: { api_key_env: 'GLM_API_KEY', base_url_env: '' },
|
||||
'kimi-coding-cn': { api_key_env: 'KIMI_CN_API_KEY', base_url_env: '' },
|
||||
moonshot: { api_key_env: 'MOONSHOT_API_KEY', base_url_env: '' },
|
||||
minimax: { api_key_env: 'MINIMAX_API_KEY', base_url_env: '' },
|
||||
'minimax-cn': { api_key_env: 'MINIMAX_CN_API_KEY', base_url_env: '' },
|
||||
deepseek: { api_key_env: 'DEEPSEEK_API_KEY', base_url_env: '' },
|
||||
alibaba: { api_key_env: 'DASHSCOPE_API_KEY', base_url_env: '' },
|
||||
anthropic: { api_key_env: 'ANTHROPIC_API_KEY', base_url_env: '' },
|
||||
xai: { api_key_env: 'XAI_API_KEY', base_url_env: '' },
|
||||
xiaomi: { api_key_env: 'XIAOMI_API_KEY', base_url_env: '' },
|
||||
gemini: { api_key_env: 'GEMINI_API_KEY', base_url_env: '' },
|
||||
kilocode: { api_key_env: 'KILO_API_KEY', base_url_env: '' },
|
||||
'ai-gateway': { api_key_env: 'AI_GATEWAY_API_KEY', base_url_env: '' },
|
||||
'opencode-zen': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' },
|
||||
'opencode-go': { api_key_env: 'OPENCODE_API_KEY', base_url_env: '' },
|
||||
huggingface: { api_key_env: 'HF_TOKEN', base_url_env: '' },
|
||||
arcee: { api_key_env: 'ARCEE_API_KEY', base_url_env: '' },
|
||||
'openai-codex': { api_key_env: '', base_url_env: '' },
|
||||
}
|
||||
|
||||
async function saveEnvValue(key: string, value: string): Promise<void> {
|
||||
const envPath = getActiveEnvPath()
|
||||
let raw: string
|
||||
try {
|
||||
raw = await readFile(envPath, 'utf-8')
|
||||
} catch {
|
||||
raw = ''
|
||||
}
|
||||
const remove = !value
|
||||
const lines = raw.split('\n')
|
||||
let found = false
|
||||
const result: string[] = []
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('#') && trimmed.startsWith(`# ${key}=`)) {
|
||||
if (!remove) result.push(`${key}=${value}`)
|
||||
found = true
|
||||
} else {
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
|
||||
if (!remove) result.push(`${key}=${value}`)
|
||||
found = true
|
||||
} else {
|
||||
result.push(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found && !remove) {
|
||||
result.push(`${key}=${value}`)
|
||||
}
|
||||
let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n'
|
||||
await writeFile(envPath, output, 'utf-8')
|
||||
}
|
||||
|
||||
// --- Auth / Credential Pool ---
|
||||
|
||||
async function fetchProviderModels(baseUrl: string, apiKey: string): Promise<string[]> {
|
||||
try {
|
||||
const url = baseUrl.replace(/\/+$/, '') + '/models'
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(8000),
|
||||
})
|
||||
if (!res.ok) {
|
||||
console.warn(`[available-models] ${baseUrl} returned ${res.status}`)
|
||||
return []
|
||||
}
|
||||
const data = await res.json() as { data?: Array<{ id: string }> }
|
||||
if (!Array.isArray(data.data)) {
|
||||
console.warn(`[available-models] ${baseUrl} returned unexpected format`)
|
||||
return []
|
||||
}
|
||||
return data.data.map(m => m.id).sort()
|
||||
} catch (err: any) {
|
||||
console.error(`[available-models] ${baseUrl} failed: ${err.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
|
||||
import { buildProviderModelMap } from '../../shared/providers'
|
||||
const PROVIDER_MODEL_CATALOG = buildProviderModelMap()
|
||||
|
||||
export const fsRoutes = new Router()
|
||||
|
||||
const hermesDir = () => getActiveProfileDir()
|
||||
|
||||
// --- Types ---
|
||||
|
||||
interface SkillInfo {
|
||||
name: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface SkillCategory {
|
||||
name: string
|
||||
description: string
|
||||
skills: SkillInfo[]
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function extractDescription(content: string): string {
|
||||
const lines = content.split('\n')
|
||||
let inFrontmatter = false
|
||||
let bodyStarted = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (!bodyStarted && line.trim() === '---') {
|
||||
if (!inFrontmatter) {
|
||||
inFrontmatter = true
|
||||
continue
|
||||
} else {
|
||||
inFrontmatter = false
|
||||
bodyStarted = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (inFrontmatter) continue
|
||||
if (line.trim() === '') continue
|
||||
if (line.startsWith('#')) continue
|
||||
return line.trim().slice(0, 80)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
async function safeReadFile(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf-8')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function safeStat(filePath: string): Promise<{ mtime: number } | null> {
|
||||
try {
|
||||
const s = await stat(filePath)
|
||||
return { mtime: Math.round(s.mtimeMs) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config YAML helpers ---
|
||||
|
||||
const configPath = () => getActiveConfigPath()
|
||||
|
||||
async function readConfigYaml(): Promise<Record<string, any>> {
|
||||
const raw = await safeReadFile(configPath())
|
||||
if (!raw) return {}
|
||||
return (YAML.load(raw) as Record<string, any>) || {}
|
||||
}
|
||||
|
||||
async function writeConfigYaml(config: Record<string, any>): Promise<void> {
|
||||
const cp = configPath()
|
||||
await copyFile(cp, cp + '.bak')
|
||||
const yamlStr = YAML.dump(config, {
|
||||
lineWidth: -1,
|
||||
noRefs: true,
|
||||
quotingType: '"',
|
||||
})
|
||||
await writeFile(cp, yamlStr, 'utf-8')
|
||||
}
|
||||
|
||||
// --- Skills Routes ---
|
||||
|
||||
// List all skills grouped by category
|
||||
fsRoutes.get('/api/hermes/skills', async (ctx) => {
|
||||
const skillsDir = join(hermesDir(), 'skills')
|
||||
|
||||
try {
|
||||
// Read disabled skills list from config.yaml
|
||||
const config = await readConfigYaml()
|
||||
const disabledList: string[] = config.skills?.disabled || []
|
||||
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
||||
const categories: SkillCategory[] = []
|
||||
|
||||
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: SkillInfo[] = []
|
||||
|
||||
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, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
|
||||
ctx.body = { categories }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `Failed to read skills directory: ${err.message}` }
|
||||
}
|
||||
})
|
||||
|
||||
// Toggle skill enabled/disabled via config.yaml skills.disabled
|
||||
fsRoutes.put('/api/hermes/skills/toggle', async (ctx) => {
|
||||
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) {
|
||||
// Enable: remove from disabled list
|
||||
if (idx !== -1) disabled.splice(idx, 1)
|
||||
} else {
|
||||
// Disable: add to disabled list
|
||||
if (idx === -1) disabled.push(name)
|
||||
}
|
||||
|
||||
await writeConfigYaml(config)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// List files in a skill directory
|
||||
async function listFilesRecursive(dir: string, prefix: string): Promise<{ path: string; name: string }[]> {
|
||||
const result: { path: string; name: string }[] = []
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const relPath = prefix ? `${prefix}/${entry.name}` : entry.name
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...await listFilesRecursive(join(dir, entry.name), relPath))
|
||||
} else {
|
||||
result.push({ path: relPath, name: entry.name })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fsRoutes.get('/api/hermes/skills/:category/:skill/files', async (ctx) => {
|
||||
const { category, skill } = ctx.params
|
||||
const skillDir = join(hermesDir(), '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 }
|
||||
}
|
||||
})
|
||||
|
||||
// Read a specific file under skills/ (must be registered after the /files route)
|
||||
fsRoutes.get('/api/hermes/skills/{*path}', async (ctx) => {
|
||||
const filePath = (ctx.params as any).path
|
||||
const hd = hermesDir()
|
||||
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 }
|
||||
})
|
||||
|
||||
// --- Memory Routes ---
|
||||
|
||||
fsRoutes.get('/api/hermes/memory', async (ctx) => {
|
||||
const hd = hermesDir()
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
||||
fsRoutes.post('/api/hermes/memory', async (ctx) => {
|
||||
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(hermesDir(), 'SOUL.md')
|
||||
} else {
|
||||
const fileName = section === 'memory' ? 'MEMORY.md' : 'USER.md'
|
||||
filePath = join(hermesDir(), 'memories', fileName)
|
||||
}
|
||||
|
||||
try {
|
||||
await writeFile(filePath, content, 'utf-8')
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// --- Config Model Routes ---
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
interface ModelGroup {
|
||||
provider: string
|
||||
models: ModelInfo[]
|
||||
}
|
||||
|
||||
// Build model list from user's actual config.yaml using js-yaml
|
||||
function buildModelGroups(config: Record<string, any>): { default: string; groups: ModelGroup[] } {
|
||||
let defaultModel = ''
|
||||
const groups: ModelGroup[] = []
|
||||
const allModelIds = new Set<string>()
|
||||
|
||||
// 1. Extract current model
|
||||
const modelSection = config.model
|
||||
if (typeof modelSection === 'object' && modelSection !== null) {
|
||||
defaultModel = String(modelSection.default || '').trim()
|
||||
} else if (typeof modelSection === 'string') {
|
||||
defaultModel = modelSection.trim()
|
||||
}
|
||||
|
||||
// 2. Extract custom_providers section
|
||||
const customProviders = config.custom_providers
|
||||
if (Array.isArray(customProviders)) {
|
||||
const customModels: ModelInfo[] = []
|
||||
for (const entry of customProviders) {
|
||||
if (entry && typeof entry === 'object') {
|
||||
const cName = String(entry.name || '').trim()
|
||||
const cModel = String(entry.model || '').trim()
|
||||
if (cName && cModel) {
|
||||
customModels.push({ id: cModel, label: `${cName}: ${cModel}` })
|
||||
allModelIds.add(cModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (customModels.length > 0) {
|
||||
groups.push({ provider: 'Custom', models: customModels })
|
||||
}
|
||||
}
|
||||
|
||||
return { default: defaultModel, groups }
|
||||
}
|
||||
|
||||
// GET /api/available-models — resolve models from .env authorized providers + credential pool + custom providers
|
||||
fsRoutes.get('/api/hermes/available-models', async (ctx) => {
|
||||
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>()
|
||||
|
||||
// 1. Read .env to discover authorized providers via PROVIDER_ENV_MAP
|
||||
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 })
|
||||
}
|
||||
|
||||
// Import PROVIDER_PRESETS for label + base_url lookup
|
||||
const { PROVIDER_PRESETS } = await import('../../shared/providers')
|
||||
|
||||
// 1. Authorized providers from .env + OAuth-based providers (no api_key_env)
|
||||
// Check OAuth auth (e.g. openai-codex) via auth.json
|
||||
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)) {
|
||||
// Skip providers that require API key but don't have one configured
|
||||
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
|
||||
// Skip OAuth providers that haven't been authenticated
|
||||
if (!envMapping.api_key_env && !isOAuthAuthorized(providerKey)) continue
|
||||
const preset = PROVIDER_PRESETS.find(p => 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Custom providers from config.yaml — dynamically fetch models
|
||||
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] // always include the statically configured 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
|
||||
const existing = groups.find(g => g.base_url.replace(/\/+$/, '') === base_url)
|
||||
if (existing) {
|
||||
for (const m of models) {
|
||||
if (!existing.models.includes(m)) existing.models.push(m)
|
||||
}
|
||||
} else {
|
||||
addGroup(providerKey, label, base_url, models, cpApiKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate models within each group
|
||||
for (const g of groups) {
|
||||
g.models = Array.from(new Set(g.models))
|
||||
}
|
||||
|
||||
// Fallback: if still no providers, fall back to config.yaml parsing
|
||||
if (groups.length === 0) {
|
||||
const fallback = buildModelGroups(config)
|
||||
const allProviders = PROVIDER_PRESETS.map(p => ({
|
||||
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 => ({
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/config/models
|
||||
fsRoutes.get('/api/hermes/config/models', async (ctx) => {
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
ctx.body = buildModelGroups(config)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/model
|
||||
fsRoutes.put('/api/hermes/config/model', async (ctx) => {
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/config/providers
|
||||
fsRoutes.post('/api/hermes/config/providers', async (ctx) => {
|
||||
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 {
|
||||
// Determine if this is a built-in provider or a custom one
|
||||
const poolKey = providerKey
|
||||
|| `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
const isBuiltin = poolKey in PROVIDER_ENV_MAP
|
||||
|
||||
if (!isBuiltin) {
|
||||
// Custom provider: write to config.yaml custom_providers
|
||||
const config = await readConfigYaml()
|
||||
if (!Array.isArray(config.custom_providers)) {
|
||||
config.custom_providers = []
|
||||
}
|
||||
config.custom_providers.push({ name, base_url, api_key, model })
|
||||
await writeConfigYaml(config)
|
||||
}
|
||||
|
||||
// Write API key to .env (built-in providers only)
|
||||
const envMapping = PROVIDER_ENV_MAP[poolKey] || PROVIDER_ENV_MAP[providerKey || '']
|
||||
if (envMapping) {
|
||||
await saveEnvValue(envMapping.api_key_env, api_key)
|
||||
if (envMapping.base_url_env) {
|
||||
await saveEnvValue(envMapping.base_url_env, base_url)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-switch model to the newly added provider
|
||||
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)
|
||||
|
||||
// Restart gateway to pick up .env and config.yaml changes
|
||||
try {
|
||||
await hermesCli.restartGateway()
|
||||
} catch (e: any) {
|
||||
console.error('[Provider] Gateway restart failed:', e.message)
|
||||
}
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/config/providers/:poolKey — update existing provider
|
||||
fsRoutes.put('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
||||
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) {
|
||||
// Update custom provider in config.yaml
|
||||
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) => {
|
||||
const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
return key === 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 {
|
||||
// Built-in provider: update API key in .env
|
||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||
if (!envMapping?.api_key_env) {
|
||||
// OAuth provider — cannot update key
|
||||
ctx.status = 400
|
||||
ctx.body = { error: `Cannot update credentials for "${poolKey}"` }
|
||||
return
|
||||
}
|
||||
if (api_key !== undefined) {
|
||||
await saveEnvValue(envMapping.api_key_env, api_key)
|
||||
}
|
||||
}
|
||||
|
||||
// Restart gateway to pick up changes
|
||||
try {
|
||||
await hermesCli.restartGateway()
|
||||
} catch (e: any) {
|
||||
console.error('[Provider] Gateway restart failed:', e.message)
|
||||
}
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/config/providers/:poolKey
|
||||
fsRoutes.delete('/api/hermes/config/providers/:poolKey', async (ctx) => {
|
||||
const poolKey = decodeURIComponent(ctx.params.poolKey)
|
||||
|
||||
try {
|
||||
const config = await readConfigYaml()
|
||||
const isCustom = poolKey.startsWith('custom:')
|
||||
|
||||
if (isCustom) {
|
||||
// Delete from config.yaml custom_providers
|
||||
const idx = Array.isArray(config.custom_providers)
|
||||
? (config.custom_providers as any[]).findIndex((e: any) => {
|
||||
const key = `custom:${e.name.trim().toLowerCase().replace(/ /g, '-')}`
|
||||
return key === 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 {
|
||||
// Built-in provider: remove API key from .env
|
||||
const envMapping = PROVIDER_ENV_MAP[poolKey]
|
||||
if (envMapping?.api_key_env) {
|
||||
await saveEnvValue(envMapping.api_key_env, '')
|
||||
} else if (!envMapping?.api_key_env) {
|
||||
// OAuth provider (e.g. openai-codex): clear tokens from auth.json
|
||||
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]
|
||||
}
|
||||
const { writeFile: wfs } = await import('fs/promises')
|
||||
await wfs(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Provider] Failed to clear OAuth tokens for ${poolKey}:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If was the current provider, switch to first remaining
|
||||
const currentProvider = config.model?.provider
|
||||
const isCurrent = currentProvider === poolKey
|
||||
if (isCurrent) {
|
||||
// Find fallback from .env authorized providers or remaining custom_providers
|
||||
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 }
|
||||
}
|
||||
})
|
||||
@@ -1,71 +1,9 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/gateways'
|
||||
|
||||
export const gatewayRoutes = new Router()
|
||||
|
||||
// Get singleton instance — set during bootstrap
|
||||
let manager: any = null
|
||||
|
||||
export function setGatewayManager(mgr: any) {
|
||||
manager = mgr
|
||||
}
|
||||
|
||||
export function getGatewayManager(): any {
|
||||
return manager
|
||||
}
|
||||
|
||||
// List all gateway statuses
|
||||
gatewayRoutes.get('/api/hermes/gateways', async (ctx) => {
|
||||
if (!manager) {
|
||||
ctx.status = 503
|
||||
ctx.body = { error: 'GatewayManager not initialized' }
|
||||
return
|
||||
}
|
||||
const gateways = await manager.listAll()
|
||||
ctx.body = { gateways }
|
||||
})
|
||||
|
||||
// Start a profile's gateway
|
||||
gatewayRoutes.post('/api/hermes/gateways/:name/start', async (ctx) => {
|
||||
if (!manager) {
|
||||
ctx.status = 503
|
||||
ctx.body = { error: 'GatewayManager not initialized' }
|
||||
return
|
||||
}
|
||||
const { name } = ctx.params
|
||||
try {
|
||||
const status = await manager.start(name)
|
||||
ctx.body = { success: true, gateway: status }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Stop a profile's gateway
|
||||
gatewayRoutes.post('/api/hermes/gateways/:name/stop', async (ctx) => {
|
||||
if (!manager) {
|
||||
ctx.status = 503
|
||||
ctx.body = { error: 'GatewayManager not initialized' }
|
||||
return
|
||||
}
|
||||
const { name } = ctx.params
|
||||
try {
|
||||
await manager.stop(name)
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// Check a profile's gateway health
|
||||
gatewayRoutes.get('/api/hermes/gateways/:name/health', async (ctx) => {
|
||||
if (!manager) {
|
||||
ctx.status = 503
|
||||
ctx.body = { error: 'GatewayManager not initialized' }
|
||||
return
|
||||
}
|
||||
const { name } = ctx.params
|
||||
const status = await manager.detectStatus(name)
|
||||
ctx.body = { gateway: status }
|
||||
})
|
||||
gatewayRoutes.get('/api/hermes/gateways', ctrl.list)
|
||||
gatewayRoutes.post('/api/hermes/gateways/:name/start', ctrl.start)
|
||||
gatewayRoutes.post('/api/hermes/gateways/:name/stop', ctrl.stop)
|
||||
gatewayRoutes.get('/api/hermes/gateways/:name/health', ctrl.health)
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import Router from '@koa/router'
|
||||
import { sessionRoutes } from './sessions'
|
||||
import { profileRoutes } from './profiles'
|
||||
import { configRoutes } from './config'
|
||||
import { fsRoutes } from './filesystem'
|
||||
import { logRoutes } from './logs'
|
||||
import { weixinRoutes } from './weixin'
|
||||
import { codexAuthRoutes } from './codex-auth'
|
||||
import { gatewayRoutes } from './gateways'
|
||||
import { proxyRoutes, proxyMiddleware } from './proxy'
|
||||
import { setupTerminalWebSocket } from './terminal'
|
||||
|
||||
export const hermesRoutes = new Router()
|
||||
|
||||
hermesRoutes.use(sessionRoutes.routes())
|
||||
hermesRoutes.use(profileRoutes.routes())
|
||||
hermesRoutes.use(configRoutes.routes())
|
||||
hermesRoutes.use(fsRoutes.routes())
|
||||
hermesRoutes.use(logRoutes.routes())
|
||||
hermesRoutes.use(weixinRoutes.routes())
|
||||
hermesRoutes.use(codexAuthRoutes.routes())
|
||||
hermesRoutes.use(gatewayRoutes.routes())
|
||||
hermesRoutes.use(proxyRoutes.routes())
|
||||
|
||||
export { setupTerminalWebSocket, proxyMiddleware }
|
||||
@@ -1,101 +1,7 @@
|
||||
import Router from '@koa/router'
|
||||
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'
|
||||
import * as ctrl from '../../controllers/hermes/logs'
|
||||
|
||||
export const logRoutes = new Router()
|
||||
|
||||
const WEBUI_LOG_FILE = join(homedir(), '.hermes-web-ui', 'server.log')
|
||||
|
||||
// List available log files
|
||||
logRoutes.get('/api/hermes/logs', async (ctx) => {
|
||||
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 }
|
||||
})
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
logger: string
|
||||
message: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
// Parse a single log line into structured entry
|
||||
function parseLine(line: string): LogEntry {
|
||||
// Match: 2026-04-11 20:16:16,289 INFO aiohttp.access: message (agent log format)
|
||||
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: [Lark] [2026-04-19 18:46:54,864] [INFO] message (gateway log format)
|
||||
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 }
|
||||
}
|
||||
// Unparseable line — keep as raw entry so nothing is lost
|
||||
return { timestamp: '', level: '', logger: '', message: line, raw: line }
|
||||
}
|
||||
|
||||
// Read log lines (parsed)
|
||||
logRoutes.get('/api/hermes/logs/:name', async (ctx) => {
|
||||
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
|
||||
|
||||
// Handle hermes-web-ui's own server log
|
||||
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 }
|
||||
} 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) {
|
||||
// Skip header lines like "--- ~/.hermes/logs/agent.log (last 100) ---"
|
||||
if (line.startsWith('---') || line.trim() === '') continue
|
||||
entries.push(parseLine(line))
|
||||
}
|
||||
|
||||
ctx.body = { entries }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
logRoutes.get('/api/hermes/logs', ctrl.list)
|
||||
logRoutes.get('/api/hermes/logs/:name', ctrl.read)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/memory'
|
||||
|
||||
export const memoryRoutes = new Router()
|
||||
|
||||
memoryRoutes.get('/api/hermes/memory', ctrl.get)
|
||||
memoryRoutes.post('/api/hermes/memory', ctrl.save)
|
||||
@@ -0,0 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/models'
|
||||
|
||||
export const modelRoutes = new Router()
|
||||
|
||||
modelRoutes.get('/api/hermes/available-models', ctrl.getAvailable)
|
||||
modelRoutes.get('/api/hermes/config/models', ctrl.getConfigModels)
|
||||
modelRoutes.put('/api/hermes/config/model', ctrl.setConfigModel)
|
||||
@@ -1,257 +1,13 @@
|
||||
import Router from '@koa/router'
|
||||
import { createReadStream, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { tmpdir, homedir } from 'os'
|
||||
import YAML from 'js-yaml'
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { getGatewayManager } from './gateways'
|
||||
import * as ctrl from '../../controllers/hermes/profiles'
|
||||
|
||||
export const profileRoutes = new Router()
|
||||
|
||||
// GET /api/profiles - List all profiles
|
||||
profileRoutes.get('/api/hermes/profiles', async (ctx) => {
|
||||
try {
|
||||
const profiles = await hermesCli.listProfiles()
|
||||
ctx.body = { profiles }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles - Create a new profile
|
||||
profileRoutes.post('/api/hermes/profiles', async (ctx) => {
|
||||
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)
|
||||
|
||||
// 创建完成后启动该 profile 的网关
|
||||
const mgr = getGatewayManager()
|
||||
if (mgr) {
|
||||
try { await mgr.start(name) } catch (err: any) {
|
||||
console.error(`[Profile] Failed to start gateway for ${name}:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.body = { success: true, message: output.trim() }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/profiles/:name - Get profile details
|
||||
profileRoutes.get('/api/hermes/profiles/:name', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
|
||||
try {
|
||||
const profile = await hermesCli.getProfile(name)
|
||||
ctx.body = { profile }
|
||||
} catch (err: any) {
|
||||
ctx.status = err.message.includes('not found') ? 404 : 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/profiles/:name - Delete a profile
|
||||
profileRoutes.delete('/api/hermes/profiles/:name', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
|
||||
if (name === 'default') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Cannot delete the default profile' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Stop gateway for this profile before deleting
|
||||
const mgr = getGatewayManager()
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/:name/rename - Rename a profile
|
||||
profileRoutes.post('/api/hermes/profiles/:name/rename', async (ctx) => {
|
||||
const { name } = ctx.params
|
||||
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(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 }
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/profiles/active - Switch active profile
|
||||
profileRoutes.put('/api/hermes/profiles/active', async (ctx) => {
|
||||
const { name } = ctx.request.body as { name?: string }
|
||||
|
||||
if (!name) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing profile name' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Switch profile only (no gateway stop/restart)
|
||||
const output = await hermesCli.useProfile(name)
|
||||
await new Promise(r => setTimeout(r, 1000))
|
||||
|
||||
// 2. Update GatewayManager active profile
|
||||
const mgr = getGatewayManager()
|
||||
if (mgr) {
|
||||
mgr.setActiveProfile(name)
|
||||
}
|
||||
|
||||
// 3. Ensure api_server config for new profile
|
||||
try {
|
||||
const detail = await hermesCli.getProfile(name)
|
||||
console.log(`[Profile] detail.path = ${detail.path}`)
|
||||
if (!existsSync(join(detail.path, 'config.yaml'))) {
|
||||
// No config.yaml — run setup --reset to create full default config
|
||||
try { await hermesCli.setupReset() } catch { }
|
||||
}
|
||||
// Create .env if target has none
|
||||
const profileEnv = join(detail.path, '.env')
|
||||
if (!existsSync(profileEnv)) {
|
||||
writeFileSync(profileEnv, '# Hermes Agent Environment Configuration\n', 'utf-8')
|
||||
console.log(`[Profile] Created .env for: ${detail.path}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[Profile] Ensure config failed:`, err.message)
|
||||
}
|
||||
|
||||
ctx.body = { success: true, message: output.trim() }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/:name/export - Export profile to archive and download
|
||||
profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
|
||||
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)
|
||||
|
||||
// Clean up temp file after response ends
|
||||
ctx.res.on('finish', () => {
|
||||
try { unlinkSync(outputPath) } catch { }
|
||||
})
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/profiles/import - Import profile from uploaded archive
|
||||
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
|
||||
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 })
|
||||
|
||||
// Read raw body and parse multipart
|
||||
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)
|
||||
|
||||
// Clean up temp file
|
||||
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 }
|
||||
}
|
||||
})
|
||||
profileRoutes.get('/api/hermes/profiles', ctrl.list)
|
||||
profileRoutes.post('/api/hermes/profiles', ctrl.create)
|
||||
profileRoutes.get('/api/hermes/profiles/:name', ctrl.get)
|
||||
profileRoutes.delete('/api/hermes/profiles/:name', ctrl.remove)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/rename', ctrl.rename)
|
||||
profileRoutes.put('/api/hermes/profiles/active', ctrl.switchProfile)
|
||||
profileRoutes.post('/api/hermes/profiles/:name/export', ctrl.exportProfile)
|
||||
profileRoutes.post('/api/hermes/profiles/import', ctrl.importProfile)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/providers'
|
||||
|
||||
export const providerRoutes = new Router()
|
||||
|
||||
providerRoutes.post('/api/hermes/config/providers', ctrl.create)
|
||||
providerRoutes.put('/api/hermes/config/providers/:poolKey', ctrl.update)
|
||||
providerRoutes.delete('/api/hermes/config/providers/:poolKey', ctrl.remove)
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Context } from 'koa'
|
||||
import { config } from '../../config'
|
||||
import { getGatewayManager } from './gateways'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
|
||||
function getGatewayManager() { return getGatewayManagerInstance() }
|
||||
|
||||
function isTransientGatewayError(err: any): boolean {
|
||||
const msg = String(err?.message || '')
|
||||
|
||||
@@ -1,61 +1,9 @@
|
||||
import Router from '@koa/router'
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { listSessionSummaries } from '../../services/hermes/sessions-db'
|
||||
import * as ctrl from '../../controllers/hermes/sessions'
|
||||
|
||||
export const sessionRoutes = new Router()
|
||||
|
||||
// List sessions from Hermes
|
||||
sessionRoutes.get('/api/hermes/sessions', async (ctx) => {
|
||||
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) {
|
||||
console.warn('[Hermes Session DB] summary query failed, falling back to CLI:', err)
|
||||
}
|
||||
|
||||
const sessions = await hermesCli.listSessions(source, limit)
|
||||
ctx.body = { sessions }
|
||||
})
|
||||
|
||||
// Get single session with messages
|
||||
sessionRoutes.get('/api/hermes/sessions/:id', async (ctx) => {
|
||||
const session = await hermesCli.getSession(ctx.params.id)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = { session }
|
||||
})
|
||||
|
||||
// Delete session from Hermes
|
||||
sessionRoutes.delete('/api/hermes/sessions/:id', async (ctx) => {
|
||||
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 }
|
||||
})
|
||||
|
||||
// Rename session
|
||||
sessionRoutes.post('/api/hermes/sessions/:id/rename', async (ctx) => {
|
||||
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 }
|
||||
})
|
||||
sessionRoutes.get('/api/hermes/sessions', ctrl.list)
|
||||
sessionRoutes.get('/api/hermes/sessions/:id', ctrl.get)
|
||||
sessionRoutes.delete('/api/hermes/sessions/:id', ctrl.remove)
|
||||
sessionRoutes.post('/api/hermes/sessions/:id/rename', ctrl.rename)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/skills'
|
||||
|
||||
export const skillRoutes = new Router()
|
||||
|
||||
skillRoutes.get('/api/hermes/skills', ctrl.list)
|
||||
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
|
||||
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
|
||||
skillRoutes.get('/api/hermes/skills/{*path}', ctrl.readFile_)
|
||||
@@ -3,6 +3,7 @@ import type { Server as HttpServer } from 'http'
|
||||
import { accessSync, chmodSync, constants as fsConstants, existsSync } from 'fs'
|
||||
import { dirname, join } from 'path'
|
||||
import { getToken } from '../../services/auth'
|
||||
import { logger } from '../../services/logger'
|
||||
|
||||
let pty: any = null
|
||||
|
||||
@@ -23,11 +24,11 @@ function ensureNodePtySpawnHelperExecutable() {
|
||||
accessSync(helperPath, fsConstants.X_OK)
|
||||
} catch {
|
||||
chmodSync(helperPath, 0o755)
|
||||
console.log(`[Terminal] Restored execute bit for node-pty helper: ${helperPath}`)
|
||||
logger.debug('Restored execute bit for node-pty helper: %s', helperPath)
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(`[Terminal] Could not normalize node-pty helper permissions: ${err?.message || err}`)
|
||||
logger.warn(err, 'Could not normalize node-pty helper permissions')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +37,7 @@ try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
pty = require('node-pty')
|
||||
} catch (err: any) {
|
||||
console.warn(`[Terminal] node-pty failed to load, terminal feature disabled (${err?.message || 'unknown error'})`)
|
||||
logger.warn(err, 'node-pty failed to load, terminal feature disabled')
|
||||
}
|
||||
|
||||
// ─── Shell detection ────────────────────────────────────────────
|
||||
@@ -111,7 +112,7 @@ function createSession(shell: string): PtySession {
|
||||
|
||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
if (!pty) {
|
||||
console.warn('[Terminal] node-pty not available, skipping terminal WebSocket setup')
|
||||
logger.warn('node-pty not available, skipping terminal WebSocket setup')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -176,7 +177,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
||||
}
|
||||
conn.sessions.delete(session.id)
|
||||
console.log(`[Terminal] Session ${session.id} exited (pid ${session.pid}, code ${exitCode})`)
|
||||
logger.info('Session %s exited (pid %d, code %d)', session.id, session.pid, exitCode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -227,7 +228,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
pid: session.pid,
|
||||
shell: shellName(shell),
|
||||
}))
|
||||
console.log(`[Terminal] Session created: ${session.id} (${shellName(shell)}, pid ${session.pid})`)
|
||||
logger.info('Session created: %s (%s, pid %d)', session.id, shellName(shell), session.pid)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -252,7 +253,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
conn.outputBuffers.delete(sessionId)
|
||||
}
|
||||
|
||||
console.log(`[Terminal] Switched to session ${sessionId}`)
|
||||
logger.debug('Switched to session %s', sessionId)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -268,7 +269,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
const remaining = Array.from(conn.sessions.keys())
|
||||
conn.activeSessionId = remaining.length > 0 ? remaining[0] : null
|
||||
}
|
||||
console.log(`[Terminal] Session closed: ${sessionId}`)
|
||||
logger.info('Session closed: %s', sessionId)
|
||||
break
|
||||
}
|
||||
|
||||
@@ -290,7 +291,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
try { session.pty.kill() } catch { }
|
||||
}
|
||||
conn.sessions.clear()
|
||||
console.log(`[Terminal] Connection closed, all sessions killed`)
|
||||
logger.info('Connection closed, all sessions killed')
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
@@ -307,7 +308,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
firstSession = createSession(defaultShell)
|
||||
} catch (err: any) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: err.message }))
|
||||
console.error(`[Terminal] Failed to create session: ${err.message}`)
|
||||
logger.error(err, 'Failed to create session')
|
||||
ws.close()
|
||||
return
|
||||
}
|
||||
@@ -320,8 +321,8 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||
pid: firstSession.pid,
|
||||
shell: shellName(defaultShell),
|
||||
}))
|
||||
console.log(`[Terminal] First session created: ${firstSession.id} (${shellName(defaultShell)}, pid ${firstSession.pid})`)
|
||||
logger.info('First session created: %s (%s, pid %d)', firstSession.id, shellName(defaultShell), firstSession.pid)
|
||||
})
|
||||
|
||||
console.log(`[Terminal] WebSocket ready at /terminal (shell: ${defaultShell}, transport: node-pty)`)
|
||||
logger.info('WebSocket ready at /terminal (shell: %s, transport: node-pty)', defaultShell)
|
||||
}
|
||||
|
||||
@@ -1,137 +1,8 @@
|
||||
import Router from '@koa/router'
|
||||
import axios from 'axios'
|
||||
import { readFile, writeFile } from 'fs/promises'
|
||||
import { chmod } from 'fs/promises'
|
||||
import { resolve } from 'path'
|
||||
import { restartGateway } from '../../services/hermes/hermes-cli'
|
||||
import { getActiveEnvPath } from '../../services/hermes/hermes-profile'
|
||||
|
||||
const envPath = () => getActiveEnvPath()
|
||||
const ILINK_BASE = 'https://ilinkai.weixin.qq.com'
|
||||
import * as ctrl from '../../controllers/hermes/weixin'
|
||||
|
||||
export const weixinRoutes = new Router()
|
||||
|
||||
// GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
|
||||
weixinRoutes.get('/api/hermes/weixin/qrcode', async (ctx) => {
|
||||
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' }
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/weixin/qrcode/status — poll QR scan status
|
||||
weixinRoutes.get('/api/hermes/weixin/qrcode/status', async (ctx) => {
|
||||
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'
|
||||
ctx.body = { status }
|
||||
|
||||
// If confirmed, return credentials so frontend can save them
|
||||
if (status === 'confirmed') {
|
||||
ctx.body = {
|
||||
status: 'confirmed',
|
||||
account_id: data.ilink_bot_id,
|
||||
token: data.bot_token,
|
||||
base_url: data.baseurl,
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message || 'Failed to poll QR status' }
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/weixin/save — save weixin credentials to .env
|
||||
weixinRoutes.post('/api/hermes/weixin/save', async (ctx) => {
|
||||
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 { /* ignore */ }
|
||||
await restartGateway()
|
||||
|
||||
ctx.body = { success: true }
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: err.message }
|
||||
}
|
||||
})
|
||||
weixinRoutes.get('/api/hermes/weixin/qrcode', ctrl.getQrcode)
|
||||
weixinRoutes.get('/api/hermes/weixin/qrcode/status', ctrl.pollStatus)
|
||||
weixinRoutes.post('/api/hermes/weixin/save', ctrl.save)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Context, Next } from 'koa'
|
||||
|
||||
// Shared route modules
|
||||
import { healthRoutes } from './health'
|
||||
import { webhookRoutes } from './webhook'
|
||||
import { uploadRoutes } from './upload'
|
||||
import { updateRoutes } from './update'
|
||||
|
||||
// Hermes route modules
|
||||
import { sessionRoutes } from './hermes/sessions'
|
||||
import { profileRoutes } from './hermes/profiles'
|
||||
import { skillRoutes } from './hermes/skills'
|
||||
import { memoryRoutes } from './hermes/memory'
|
||||
import { modelRoutes } from './hermes/models'
|
||||
import { providerRoutes } from './hermes/providers'
|
||||
import { configRoutes } from './hermes/config'
|
||||
import { logRoutes } from './hermes/logs'
|
||||
import { codexAuthRoutes } from './hermes/codex-auth'
|
||||
import { gatewayRoutes } from './hermes/gateways'
|
||||
import { weixinRoutes } from './hermes/weixin'
|
||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||
|
||||
/**
|
||||
* Register all routes on the Koa app.
|
||||
* Public routes are registered first, then auth middleware,
|
||||
* then all protected routes. Returns the proxy middleware (must be mounted last).
|
||||
*/
|
||||
export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next) => Promise<void>) {
|
||||
// --- Public routes (no auth required) ---
|
||||
app.use(healthRoutes.routes())
|
||||
app.use(webhookRoutes.routes())
|
||||
|
||||
// --- Auth middleware: all routes below require authentication ---
|
||||
app.use(requireAuth)
|
||||
|
||||
// --- Protected routes (auth required) ---
|
||||
app.use(uploadRoutes.routes())
|
||||
app.use(updateRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
|
||||
app.use(sessionRoutes.routes())
|
||||
app.use(profileRoutes.routes())
|
||||
app.use(skillRoutes.routes())
|
||||
app.use(memoryRoutes.routes())
|
||||
app.use(modelRoutes.routes())
|
||||
app.use(providerRoutes.routes())
|
||||
app.use(configRoutes.routes())
|
||||
app.use(logRoutes.routes())
|
||||
app.use(codexAuthRoutes.routes())
|
||||
app.use(gatewayRoutes.routes())
|
||||
app.use(weixinRoutes.routes())
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// Proxy catch-all middleware (must be last)
|
||||
return proxyMiddleware
|
||||
}
|
||||
@@ -1,33 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../controllers/update'
|
||||
|
||||
export const updateRoutes = new Router()
|
||||
|
||||
updateRoutes.post('/api/hermes/update', async (ctx) => {
|
||||
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(() => {
|
||||
const { spawn } = require('child_process')
|
||||
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 }
|
||||
}
|
||||
})
|
||||
updateRoutes.post('/api/hermes/update', ctrl.handleUpdate)
|
||||
|
||||
@@ -1,90 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { writeFile } from 'fs/promises'
|
||||
import { config } from '../config'
|
||||
|
||||
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024 // 50MB
|
||||
import * as ctrl from '../controllers/upload'
|
||||
|
||||
export const uploadRoutes = new Router()
|
||||
|
||||
uploadRoutes.post('/upload', async (ctx) => {
|
||||
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
|
||||
}
|
||||
|
||||
// Read raw body as Buffer with size limit
|
||||
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)
|
||||
|
||||
// Try RFC 5987 filename* first, fall back to filename
|
||||
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 }
|
||||
})
|
||||
|
||||
/**
|
||||
* Split a multipart Buffer by boundary, returning part Buffers.
|
||||
* Avoids string decoding so multi-byte characters (e.g. Chinese filenames) are preserved.
|
||||
*/
|
||||
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) {
|
||||
// Skip the \r\n after boundary
|
||||
const partStart = start + 2
|
||||
parts.push(raw.subarray(partStart, idx))
|
||||
}
|
||||
start = idx + boundary.length
|
||||
}
|
||||
return parts
|
||||
}
|
||||
uploadRoutes.post('/upload', ctrl.handleUpload)
|
||||
|
||||
@@ -1,33 +1,6 @@
|
||||
import Router from '@koa/router'
|
||||
import { emitWebhook } from '../services/hermes/hermes'
|
||||
import * as ctrl from '../controllers/webhook'
|
||||
|
||||
export const webhookRoutes = new Router()
|
||||
|
||||
/**
|
||||
* POST /webhook — receive callbacks from Hermes Agent
|
||||
*
|
||||
* Expected body:
|
||||
* {
|
||||
* "event": "run.completed" | "job.completed" | ...,
|
||||
* "run_id": "...",
|
||||
* "data": { ... }
|
||||
* }
|
||||
*
|
||||
* TODO: Add signature verification when Hermes supports webhook signing
|
||||
*/
|
||||
webhookRoutes.post('/webhook', async (ctx) => {
|
||||
const payload = ctx.request.body
|
||||
|
||||
if (!payload || !payload.event) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing event field' }
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[Webhook] Received event: ${payload.event}`)
|
||||
|
||||
// Emit to registered callbacks
|
||||
emitWebhook(payload)
|
||||
|
||||
ctx.body = { ok: true }
|
||||
})
|
||||
webhookRoutes.post('/webhook', ctrl.handleWebhook)
|
||||
|
||||
Reference in New Issue
Block a user