feat: add docker-compose deployment and harden gateway startup

- add docker-compose setup with hermes-agent + hermes-webui

- make runtime config env-driven (compose vars + HERMES_BIN)

- improve gateway startup/restart resilience in docker

- make base image configurable via BASE_IMAGE/HERMES_AGENT_IMAGE

Closes https://github.com/EKKOLearnAI/hermes-web-ui/issues/14
This commit is contained in:
P2K0
2026-04-17 03:13:24 +08:00
parent b8d121ed79
commit f0d1d2e16c
10 changed files with 290 additions and 28 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
node_modules
dist
hermes_data
*.log
.DS_Store
+1
View File
@@ -18,6 +18,7 @@ ROADMAP.md
packages/server/data/
packages/server/node_modules/
.hermes-web-ui/
hermes_data/
# Editor directories and files
.vscode/*
+33
View File
@@ -0,0 +1,33 @@
ARG BASE_IMAGE=nousresearch/hermes-agent:latest
FROM ${BASE_IMAGE}
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gnupg \
python3 \
python3-yaml \
make \
g++ \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build && npm prune --omit=dev
ENV NODE_ENV=production
ENV HOME=/home/agent
ENV HERMES_HOME=/home/agent/.hermes
EXPOSE 6060
CMD ["node", "dist/server/index.js"]
+26
View File
@@ -144,6 +144,32 @@ hermes-web-ui start
> WSL auto-detects and uses `hermes gateway run` for background startup (no launchd/systemd).
### Docker Compose
Run Web UI together with Hermes Agent:
```bash
docker compose up -d --build hermes-agent hermes-webui
docker compose logs -f hermes-webui
```
Open **http://localhost:6060**
- Persistent Hermes data is stored in `./hermes_data`
- The web UI service is built from this repository's `Dockerfile`
- All runtime settings are environment-variable driven in `docker-compose.yml`
Override compose variables directly from command line (no `.env` file required):
```bash
PORT=16060 \
UPSTREAM=http://127.0.0.1:8642 \
HERMES_BIN=/opt/hermes/.venv/bin/hermes \
docker compose up -d --build hermes-agent hermes-webui
```
For detailed notes and troubleshooting, see [`docs/docker.md`](./docs/docker.md).
### CLI Commands
| Command | Description |
+26
View File
@@ -145,6 +145,32 @@ hermes-web-ui start
> WSL 会自动检测并使用 `hermes gateway run` 进行后台启动(无需 launchd/systemd)。
### Docker Compose
使用仓库内置的 compose 文件联合运行 Hermes Agent + Web UI
```bash
docker compose up -d --build hermes-agent hermes-webui
docker compose logs -f hermes-webui
```
打开 **http://localhost:6060**
- Hermes 持久化数据目录:`./hermes_data`
- Web UI 服务镜像由本仓库 `Dockerfile` 本地构建
- 运行参数全部由 `docker-compose.yml` 环境变量驱动
可直接在命令行覆盖 compose 变量(不依赖 `.env` 文件):
```bash
PORT=16060 \
UPSTREAM=http://127.0.0.1:8642 \
HERMES_BIN=/opt/hermes/.venv/bin/hermes \
docker compose up -d --build hermes-agent hermes-webui
```
更详细的说明与排错见:[`docs/docker.md`](./docs/docker.md)
### CLI 命令
| 命令 | 说明 |
+39
View File
@@ -0,0 +1,39 @@
services:
hermes-agent:
image: ${HERMES_AGENT_IMAGE:-nousresearch/hermes-agent:latest}
container_name: ${HERMES_AGENT_CONTAINER_NAME:-hermes-agent}
volumes:
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
- hermes-agent-src:/opt/hermes
environment:
- HERMES_HOME=/home/agent/.hermes
stdin_open: true
tty: true
restart: unless-stopped
hermes-webui:
build:
context: .
dockerfile: Dockerfile
args:
BASE_IMAGE: ${HERMES_AGENT_IMAGE:-nousresearch/hermes-agent:latest}
image: ${WEBUI_IMAGE:-hermes-web-ui-local:latest}
container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui}
entrypoint: ["node", "dist/server/index.js"]
depends_on:
- hermes-agent
ports:
- "${PORT:-6060}:${PORT:-6060}"
volumes:
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
- hermes-agent-src:/opt/hermes
environment:
- PORT=${PORT:-6060}
- UPSTREAM=${UPSTREAM:-http://127.0.0.1:8642}
- HERMES_HOME=/home/agent/.hermes
- HERMES_BIN=${HERMES_BIN:-/opt/hermes/.venv/bin/hermes}
- AUTH_DISABLED=${AUTH_DISABLED:-true}
restart: unless-stopped
volumes:
hermes-agent-src:
+62
View File
@@ -0,0 +1,62 @@
# Docker Compose Guide
This repository ships an environment-variable driven Docker Compose setup.
## Quick Start
```bash
docker compose up -d --build hermes-agent hermes-webui
docker compose logs -f hermes-webui
```
Open: `http://localhost:6060`
## Environment Variables
All key runtime settings are configured from compose variables.
This compose file runs two services together:
- `hermes-agent` (image: `nousresearch/hermes-agent`)
- `hermes-webui` (built from this repository)
Compose mapping highlights:
- Host/browser port: `${PORT}:${PORT}`
- Server `PORT` is set from `${PORT}`
- Upstream is set from `${UPSTREAM}`
- Hermes CLI binary is set from `${HERMES_BIN}`
- Hermes base image is set from `${HERMES_AGENT_IMAGE}` (used by both `hermes-agent` and webui build base)
Override variables directly from shell when running compose:
```bash
PORT=16060 \
UPSTREAM=http://127.0.0.1:8642 \
HERMES_BIN=/opt/hermes/.venv/bin/hermes \
docker compose up -d --build hermes-agent hermes-webui
```
## Data Persistence
- Hermes runtime data persists in `${HERMES_DATA_DIR}`.
- Default path is `./hermes_data`.
## Code Runtime Behavior
- Server upstream comes from `UPSTREAM` env (`packages/server/src/config.ts`).
- Hermes CLI binary comes from `HERMES_BIN` env (`packages/server/src/services/hermes-cli.ts`).
- If `HERMES_BIN` is not provided, code falls back to `hermes` in `PATH`.
## Common Operations
Recreate webui:
```bash
docker compose up -d --no-deps --force-recreate hermes-webui
```
Stop:
```bash
docker compose down
```
+15 -6
View File
@@ -268,6 +268,17 @@ async function ensureApiServerConfig() {
async function ensureGatewayRunning() {
const upstream = config.upstream.replace(/\/$/, '')
const waitForGatewayReady = async (timeoutMs: number = 15000) => {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(2000) })
if (res.ok) return true
} catch { }
await new Promise(r => setTimeout(r, 300))
}
return false
}
try {
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
@@ -279,16 +290,14 @@ async function ensureGatewayRunning() {
try {
// 👉 关键:保存 PID
gatewayPid = await startGatewayBackground()
await new Promise(r => setTimeout(r, 3000))
const res = await fetch(`${upstream}/health`, { signal: AbortSignal.timeout(5000) })
if (res.ok) {
if (await waitForGatewayReady()) {
console.log(`✓ Gateway started (PID: ${gatewayPid})`)
} else {
console.error('gateway start failed: timed out waiting for health')
}
} catch (err: any) {
console.error('gateway start failed:', err.message)
}
}
bootstrap()
bootstrap()
@@ -1,6 +1,32 @@
import type { Context } from 'koa'
import { config } from '../../config'
function isTransientGatewayError(err: any): boolean {
const msg = String(err?.message || '')
const causeCode = String(err?.cause?.code || '')
return (
causeCode === 'ECONNREFUSED' ||
causeCode === 'ECONNRESET' ||
/ECONNREFUSED|ECONNRESET|fetch failed|socket hang up/i.test(msg)
)
}
async function waitForGatewayReady(upstream: string, timeoutMs: number = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
const healthUrl = `${upstream}/health`
while (Date.now() < deadline) {
try {
const res = await fetch(healthUrl, {
method: 'GET',
signal: AbortSignal.timeout(1200),
})
if (res.ok) return true
} catch { }
await new Promise(resolve => setTimeout(resolve, 250))
}
return false
}
export async function proxy(ctx: Context) {
const upstream = config.upstream.replace(/\/$/, '')
// Rewrite path for upstream gateway:
@@ -36,11 +62,23 @@ export async function proxy(ctx: Context) {
body = (ctx as any).request.rawBody as string | undefined
}
const res = await fetch(url, {
const requestInit: RequestInit = {
method: ctx.req.method,
headers,
body,
})
}
let res: Response
try {
res = await fetch(url, requestInit)
} catch (err: any) {
// Gateway may be restarting; wait briefly and retry once.
if (isTransientGatewayError(err) && await waitForGatewayReady(upstream)) {
res = await fetch(url, requestInit)
} else {
throw err
}
}
// Set response headers
const resHeaders: Record<string, string> = {}
+41 -20
View File
@@ -1,9 +1,19 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { promisify } from 'util'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const isDocker = existsSync('/.dockerenv')
function resolveHermesBin(): string {
const envBin = process.env.HERMES_BIN?.trim()
if (envBin) return envBin
return 'hermes'
}
const HERMES_BIN = resolveHermesBin()
export interface HermesSession {
id: string
@@ -64,7 +74,7 @@ export async function listSessions(source?: string, limit?: number): Promise<Her
if (source) args.push('--source', source)
try {
const { stdout } = await execFileAsync('hermes', args, {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024, // 50MB
timeout: 30000,
...execOpts,
@@ -128,7 +138,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
const args = ['sessions', 'export', '-', '--session-id', id]
try {
const { stdout } = await execFileAsync('hermes', args, {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 50 * 1024 * 1024,
timeout: 30000,
...execOpts,
@@ -174,7 +184,7 @@ export async function getSession(id: string): Promise<HermesSession | null> {
*/
export async function deleteSession(id: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['sessions', 'delete', id, '--yes'], {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
})
@@ -190,7 +200,7 @@ export async function deleteSession(id: string): Promise<boolean> {
*/
export async function renameSession(id: string, title: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['sessions', 'rename', id, title], {
await execFileAsync(HERMES_BIN, ['sessions', 'rename', id, title], {
timeout: 10000,
...execOpts,
})
@@ -212,7 +222,7 @@ export interface LogFileInfo {
*/
export async function getVersion(): Promise<string> {
try {
const { stdout } = await execFileAsync('hermes', ['--version'], { timeout: 5000, ...execOpts })
const { stdout } = await execFileAsync(HERMES_BIN, ['--version'], { timeout: 5000, ...execOpts })
return stdout.trim()
} catch {
return ''
@@ -223,7 +233,12 @@ export async function getVersion(): Promise<string> {
* Start Hermes gateway (uses launchd/systemd)
*/
export async function startGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'start'], {
if (isDocker) {
const pid = await startGatewayBackground()
return pid ? `Gateway started (PID: ${pid})` : 'Gateway start triggered'
}
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
...execOpts,
})
@@ -236,7 +251,7 @@ export async function startGateway(): Promise<string> {
*/
export async function startGatewayBackground(): Promise<number | null> {
const { spawn } = require('child_process') as typeof import('child_process')
const child = spawn('hermes', ['gateway', 'run'], {
const child = spawn(HERMES_BIN, ['gateway', 'run'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
@@ -249,7 +264,13 @@ export async function startGatewayBackground(): Promise<number | null> {
* Restart Hermes gateway
*/
export async function restartGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'restart'], {
if (isDocker) {
try { await stopGateway() } catch { }
const pid = await startGatewayBackground()
return pid ? `Gateway restarted (PID: ${pid})` : 'Gateway restart triggered'
}
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...execOpts,
})
@@ -260,7 +281,7 @@ export async function restartGateway(): Promise<string> {
* Stop Hermes gateway
*/
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync('hermes', ['gateway', 'stop'], {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...execOpts,
})
@@ -272,7 +293,7 @@ export async function stopGateway(): Promise<string> {
*/
export async function listLogFiles(): Promise<LogFileInfo[]> {
try {
const { stdout } = await execFileAsync('hermes', ['logs', 'list'], {
const { stdout } = await execFileAsync(HERMES_BIN, ['logs', 'list'], {
timeout: 10000,
...execOpts,
})
@@ -311,7 +332,7 @@ export async function readLogs(
if (since) args.push('--since', since)
try {
const { stdout } = await execFileAsync('hermes', args, {
const { stdout } = await execFileAsync(HERMES_BIN, args, {
maxBuffer: 10 * 1024 * 1024,
timeout: 15000,
...execOpts,
@@ -349,7 +370,7 @@ export interface HermesProfileDetail {
*/
export async function listProfiles(): Promise<HermesProfile[]> {
try {
const { stdout } = await execFileAsync('hermes', ['profile', 'list'], {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'list'], {
timeout: 10000,
...execOpts,
})
@@ -385,7 +406,7 @@ export async function listProfiles(): Promise<HermesProfile[]> {
*/
export async function getProfile(name: string): Promise<HermesProfileDetail> {
try {
const { stdout } = await execFileAsync('hermes', ['profile', 'show', name], {
const { stdout } = await execFileAsync(HERMES_BIN, ['profile', 'show', name], {
timeout: 10000,
...execOpts,
})
@@ -429,7 +450,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
if (clone) args.push('--clone')
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
timeout: 15000,
...execOpts,
})
@@ -445,7 +466,7 @@ export async function createProfile(name: string, clone?: boolean): Promise<stri
*/
export async function deleteProfile(name: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['profile', 'delete', name, '--yes'], {
await execFileAsync(HERMES_BIN, ['profile', 'delete', name, '--yes'], {
timeout: 10000,
...execOpts,
})
@@ -461,7 +482,7 @@ export async function deleteProfile(name: string): Promise<boolean> {
*/
export async function renameProfile(oldName: string, newName: string): Promise<boolean> {
try {
await execFileAsync('hermes', ['profile', 'rename', oldName, newName], {
await execFileAsync(HERMES_BIN, ['profile', 'rename', oldName, newName], {
timeout: 10000,
...execOpts,
})
@@ -477,7 +498,7 @@ export async function renameProfile(oldName: string, newName: string): Promise<b
*/
export async function useProfile(name: string): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync('hermes', ['profile', 'use', name], {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['profile', 'use', name], {
timeout: 10000,
...execOpts,
})
@@ -496,7 +517,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
if (outputPath) args.push('--output', outputPath)
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
timeout: 60000,
...execOpts,
})
@@ -512,7 +533,7 @@ export async function exportProfile(name: string, outputPath?: string): Promise<
*/
export async function setupReset(): Promise<string> {
try {
const { stdout, stderr } = await execFileAsync('hermes', ['setup', '--non-interactive', '--reset'], {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['setup', '--non-interactive', '--reset'], {
timeout: 30000,
...execOpts,
})
@@ -531,7 +552,7 @@ export async function importProfile(archivePath: string, name?: string): Promise
if (name) args.push('--name', name)
try {
const { stdout, stderr } = await execFileAsync('hermes', args, {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, args, {
timeout: 60000,
...execOpts,
})