diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 5fd6826..0c5afd2 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -12,8 +12,17 @@ const VERSION = pkg.version const PID_DIR = resolve(homedir(), '.hermes-web-ui') const PID_FILE = join(PID_DIR, 'server.pid') const LOG_FILE = join(PID_DIR, 'server.log') +const TOKEN_FILE = resolve(__dirname, '..', 'dist', 'server', 'data', '.token') const DEFAULT_PORT = 8648 +function getToken() { + try { + return readFileSync(TOKEN_FILE, 'utf-8').trim() + } catch { + return null + } +} + function getPort() { if (process.argv[3] && !isNaN(process.argv[3])) return parseInt(process.argv[3]) if (process.argv.includes('--port')) return parseInt(process.argv[process.argv.indexOf('--port') + 1]) @@ -103,6 +112,10 @@ function startDaemon(port) { console.log(` ✓ hermes-web-ui started (PID: ${child.pid}, port: ${port})`) console.log(` http://localhost:${port}`) console.log(` Log: ${LOG_FILE}`) + const token = getToken() + if (token) { + console.log(` Token: ${token}`) + } // Open browser const url = `http://localhost:${port}` const isWin = process.platform === 'win32' diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..5d23421 Binary files /dev/null and b/public/logo.png differ diff --git a/server/src/index.ts b/server/src/index.ts index cf34d01..1c587d4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -15,6 +15,7 @@ import { fsRoutes } from './routes/filesystem' import { configRoutes } from './routes/config' import { weixinRoutes } from './routes/weixin' import * as hermesCli from './services/hermes-cli' +import { getToken, authMiddleware } from './services/auth' const app = new Koa() const { restartGateway, startGateway, startGatewayBackground, getVersion } = hermesCli @@ -28,6 +29,14 @@ let gatewayPid: number | null = null export async function bootstrap() { await mkdir(config.uploadDir, { recursive: true }) await mkdir(config.dataDir, { recursive: true }) + + // Auth (after mkdir so data dir exists) + const authToken = await getToken() + if (authToken) { + app.use(await authMiddleware(authToken)) + console.log(`🔐 Auth enabled — token: ${authToken}`) + } + await ensureApiServerConfig() await ensureGatewayRunning() diff --git a/server/src/routes/filesystem.ts b/server/src/routes/filesystem.ts index e68bcae..3dfca44 100644 --- a/server/src/routes/filesystem.ts +++ b/server/src/routes/filesystem.ts @@ -69,6 +69,7 @@ const hermesDir = resolve(homedir(), '.hermes') interface SkillInfo { name: string description: string + enabled: boolean } interface SkillCategory { @@ -147,6 +148,10 @@ fsRoutes.get('/api/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[] = [] @@ -167,6 +172,7 @@ fsRoutes.get('/api/skills', async (ctx) => { skills.push({ name: se.name, description: extractDescription(skillMd), + enabled: !disabledList.includes(se.name), }) } } @@ -188,6 +194,40 @@ fsRoutes.get('/api/skills', async (ctx) => { } }) +// Toggle skill enabled/disabled via config.yaml skills.disabled +fsRoutes.put('/api/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 }[] = [] diff --git a/server/src/services/auth.ts b/server/src/services/auth.ts new file mode 100644 index 0000000..e850b3d --- /dev/null +++ b/server/src/services/auth.ts @@ -0,0 +1,72 @@ +import { readFile, writeFile } from 'fs/promises' +import { join } from 'path' +import { randomBytes } from 'crypto' +import { config } from '../config' + +// Token stored in project data directory +const TOKEN_FILE = join(config.dataDir, '.token') + +function generateToken(): string { + return randomBytes(32).toString('hex') +} + +/** + * Get or create the auth token. Returns null if auth is disabled. + */ +export async function getToken(): Promise { + // Auth can be disabled via env var + if (process.env.AUTH_DISABLED === '1' || process.env.AUTH_DISABLED === 'true') { + return null + } + + // Custom token via env var + if (process.env.AUTH_TOKEN) { + return process.env.AUTH_TOKEN + } + + try { + const token = await readFile(TOKEN_FILE, 'utf-8') + return token.trim() + } catch { + // Generate a new token + const token = generateToken() + await writeFile(TOKEN_FILE, token + '\n', { mode: 0o600 }) + return token + } +} + +/** + * Koa middleware: check Authorization header for API routes. + * Skips /health, /webhook, and static file requests. + */ +export async function authMiddleware(token: string | null) { + return async (ctx: any, next: () => Promise) => { + // If auth is disabled, skip + if (!token) { + await next() + return + } + + // Skip non-API paths (static files, health check, SPA) + const path = ctx.path + if ( + path === '/health' || + (!path.startsWith('/api') && !path.startsWith('/v1') && path !== '/upload' && path !== '/webhook') + ) { + await next() + return + } + + const auth = ctx.headers.authorization || '' + const provided = auth.startsWith('Bearer ') ? auth.slice(7) : '' + + if (!provided || provided !== token) { + ctx.status = 401 + ctx.set('Content-Type', 'application/json') + ctx.body = { error: 'Unauthorized' } + return + } + + await next() + } +} diff --git a/server/src/services/hermes-cli.ts b/server/src/services/hermes-cli.ts index bca9cab..124cfbf 100644 --- a/server/src/services/hermes-cli.ts +++ b/server/src/services/hermes-cli.ts @@ -137,6 +137,8 @@ export async function getSession(id: string): Promise { const lines = stdout.trim().split('\n').filter(Boolean) if (lines.length === 0) return null + if (!lines[0].startsWith('{')) return null + const raw: HermesSessionFull = JSON.parse(lines[0]) return { id: raw.id, diff --git a/src/App.vue b/src/App.vue index 7c0531c..8a37246 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,6 @@