fix: v1.3.0 compatibility fixes for hermes-agent

- Fix chat command args: use '-q --source tool' for non-interactive mode
  in send_session_message to avoid entering interactive CLI
- Fix auth_method KeyError: add 'auth_method: header' default in fallback config
- Persist session registry to data/sessions.json so sessions survive restarts
- Dynamic path resolution via get_hermes_home() instead of hardcoded paths
- Update README with full API documentation (v1.3.0)
- Add .gitignore for data/, __pycache__/, .venv/
This commit is contained in:
yumoqing 2026-04-25 11:37:32 +08:00
parent 57afe1264c
commit 2152ae9d40
3 changed files with 330 additions and 30 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
data/
__pycache__/
*.pyc
*.pyo
.venv/

274
README.md
View File

@ -1,24 +1,258 @@
# Hermes Service Web Application # Hermes Service API
## Overview Hermes Service 是一个将 Hermes Agent CLI 能力封装为 REST API 的中间件服务。支持多用户隔离、会话持久化、IP/API Key 安全认证。
Hermes Service is a web application that provides API access to Hermes Agent functionality. It runs in the Hermes Agent environment and exposes CLI capabilities through REST/WebSocket APIs.
## Features **版本**: v1.3.0
- Execute Hermes CLI commands via API **运行端口**: 默认 9123 (可通过 config.yaml 修改)
- Manage multiple Hermes service instances
- User and organization-based access control (using rbac module)
- Real-time communication via WebSocket
- Configuration management
## Integration ---
This module integrates with:
- **rbac**: For user and permission management
- **appbase**: For system parameter management
- **Hermes Agent**: Core functionality execution
## Directory Structure ## 启动方式
- `hermes-service/`: Python package with core logic
- `wwwroot/`: Frontend components (bricks-framework .ui files) ```bash
- `models/`: Database table definitions cd ~/repos/hermes-service
- `json/`: CRUD operation definitions python3 main.py
- `init/`: Initialization data # 或使用 uvicorn
uvicorn main:app --host 127.0.0.1 --port 9123
```
---
## 认证机制
所有 API 端点均受 `validate_ip_and_apikey()` 装饰器保护:
1. **IP 白名单**:当 `security.enable_ip_check: true` 时,仅允许 `allowed_ips` 中的 IP 访问
2. **API Key**:当 `security.enable_api_key: true` 时,需在请求头中提供有效 API Key
### API Key 传递方式
| auth_method | 请求头 | 示例 |
|-------------|--------|------|
| `header` (默认) | `X-API-Key: <key>` | `X-API-Key: 5ftyuvhfhi3345` |
| `bearer` | `Authorization: Bearer <key>` | `Authorization: Bearer 5ftyuvhfhi3345` |
---
## API 端点清单
### 1. 健康检查
```
GET /health
```
**响应**:
```json
{
"status": "healthy",
"service": "hermes-service",
"multi_user": true
}
```
### 2. 获取 Hermes 状态
```
GET /api/v1/status
```
**响应**:
```json
{
"status": "running",
"version": "Hermes Agent v0.11.0 (2026.4.23)"
}
```
### 3. 创建会话
```
POST /api/v1/sessions
Content-Type: application/json
{
"user_id": "user123"
}
```
**请求体**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `user_id` | string | 是 | 用户唯一标识 |
| `initial_message` | string | 否 | 初始消息(暂未使用) |
**响应**:
```json
{
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"user_id": "user123",
"hermes_path": "/d/hermesai/users/user123/hermes-agent",
"status": "created"
}
```
**说明**: 首次为该用户创建会话时,会自动从 BASE_HERMES_PATH 复制完整环境到 `/d/hermesai/users/{user_id}/`,实现用户隔离。
### 4. 执行任意命令
```
POST /api/v1/execute
Content-Type: application/json
{
"command": ["chat", "-q", "Hello", "--source", "tool"],
"user_context": {"user_id": "user123"},
"timeout": 300
}
```
**请求体**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `command` | list[string] | 是 | Hermes CLI 命令参数列表 |
| `user_context` | object | 否 | 用户上下文,含 `user_id` 时使用隔离环境 |
| `timeout` | int | 否 | 超时秒数,默认 300 |
**响应**:
```json
{
"success": true,
"stdout": "Hello! How can I help you today?",
"stderr": "",
"returncode": 0
}
```
### 5. 发送会话消息
```
POST /api/v1/sessions/{session_id}/messages
Content-Type: application/json
{
"message": "What is Python?"
}
```
**请求体**:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `message` | string | 是 | 发送给 AI 的消息内容 |
| `user_context` | object | 否 | 用户上下文(暂未使用,从 session 获取) |
**响应**:
```json
{
"session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"response": "Python is a high-level, general-purpose programming language...",
"success": true
}
```
**说明**: 内部使用 `hermes chat -q <message> --source tool` 非交互模式执行,确保返回单一响应而非进入交互会话。`--source tool` 标记使会话不会出现在用户的常规 CLI 会话列表中。
### 6. 获取会话信息
```
GET /api/v1/sessions/{session_id}
```
**响应**:
```json
{
"user_id": "user123",
"local_session_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"created_at": "2026-04-25T11:30:00.000000",
"status": "active"
}
```
---
## 会话持久化
会话数据持久化存储在 `data/sessions.json` 文件中,服务重启后可恢复已有会话。
**存储路径**: `<service_dir>/data/sessions.json`
**格式**: JSON
**线程安全**: 使用 threading.Lock 保护写入操作
---
## 路径解析机制
Hermes 基础路径通过以下优先级动态解析:
1. `HERMES_HOME` 环境变量(最高优先级,用于测试/开发)
2. `hermes_constants.get_hermes_home()`(从 hermes-agent 模块导入)
3. 默认回退路径 `/d/hermesai/.hermes`
这使得服务能自适应 hermes-agent 的位置变化,无需硬编码路径。
---
## 配置文件 (config.yaml)
```yaml
security:
enable_ip_check: true # 启用 IP 白名单
allowed_ips: # 允许的 IP / CIDR
- "127.0.0.1"
- "8.222.165.87"
enable_api_key: true # 启用 API Key 认证
auth_method: "header" # 认证方式: "header" 或 "bearer"
api_key_header: "X-API-Key" # API Key 请求头名称
api_keys:
- key: "your-api-key"
description: "Production key"
expires_at: null
nginx:
trusted_proxies:
- "127.0.0.1"
enable_real_ip: false # 从 X-Forwarded-For 提取真实 IP
service:
host: "127.0.0.1"
port: 9123
log_level: "info"
cors:
allow_origins: ["*"]
allow_credentials: true
allow_methods: ["*"]
allow_headers: ["*"]
```
---
## 用户隔离
每个用户拥有独立的 Hermes 环境:
```
/d/hermesai/users/
user123/
hermes-agent/ # 完整 Hermes Agent 副本
.hermes/ # 用户配置和会话数据
user456/
hermes-agent/
.hermes/
```
首次使用时自动创建,`.venv` 通过软链接共享以节省磁盘空间。
---
## v1.3.0 变更日志
| 修复项 | 说明 |
|--------|------|
| chat 命令参数 | `send_session_message` 改用 `-q --source tool` 非交互模式,避免进入交互 CLI |
| auth_method 默认值 | 默认配置中增加 `auth_method: 'header'`,防止启用 API Key 时抛 KeyError |
| 会话持久化 | 新增 `data/sessions.json` 持久化,服务重启后会话不丢失 |
| 动态路径解析 | 通过 `_resolve_hermes_home()` 动态获取 Hermes 路径,替代硬编码路径 |

79
main.py
View File

@ -7,6 +7,8 @@ import os
import sys import sys
import asyncio import asyncio
import uuid import uuid
import threading
import json as json_mod
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request
@ -19,8 +21,34 @@ import ipaddress
import yaml import yaml
from functools import wraps from functools import wraps
# Base Hermes Agent path # ---------------------------------------------------------------------------
BASE_HERMES_PATH = "/d/hermesai/.hermes/hermes-agent" # Resolve Hermes base path dynamically via get_hermes_home() when available,
# with fallback to the hardcoded default for backward compatibility.
# ---------------------------------------------------------------------------
def _resolve_hermes_home() -> str:
"""Resolve the Hermes home directory.
Tries:
1. HERMES_HOME env var (explicit override)
2. get_hermes_home() from hermes_constants module
3. Fallback to the default path
"""
env_override = os.environ.get("HERMES_HOME")
if env_override:
return env_override
try:
# Add the hermes-agent directory to the Python path so we can import
# from the bundled agent without installing it.
default_path = "/d/hermesai/.hermes/hermes-agent"
if default_path not in sys.path:
sys.path.insert(0, default_path)
from hermes_constants import get_hermes_home
return get_hermes_home()
except Exception:
return "/d/hermesai/.hermes"
HERMES_HOME = _resolve_hermes_home()
BASE_HERMES_PATH = os.path.join(HERMES_HOME, "hermes-agent")
# Clean user data directory structure: /d/hermesai/users/{user_id}/.hermes # Clean user data directory structure: /d/hermesai/users/{user_id}/.hermes
USERS_BASE = "/d/hermesai/users" USERS_BASE = "/d/hermesai/users"
@ -38,7 +66,8 @@ else:
'allowed_ips': ['127.0.0.1', '::1'], 'allowed_ips': ['127.0.0.1', '::1'],
'enable_api_key': False, 'enable_api_key': False,
'api_keys': [], 'api_keys': [],
'api_key_header': 'X-API-Key' 'api_key_header': 'X-API-Key',
'auth_method': 'header' # 'header' or 'bearer'
}, },
'nginx': { 'nginx': {
'trusted_proxies': ['127.0.0.1', '::1'], 'trusted_proxies': ['127.0.0.1', '::1'],
@ -59,7 +88,7 @@ else:
print(f"Security config - IP check: {config['security']['enable_ip_check']}, API key: {config['security']['enable_api_key']}") print(f"Security config - IP check: {config['security']['enable_ip_check']}, API key: {config['security']['enable_api_key']}")
app = FastAPI(title="Hermes Service API", version="1.2.0") app = FastAPI(title="Hermes Service API", version="1.3.0")
# Configure CORS # Configure CORS
app.add_middleware( app.add_middleware(
@ -70,8 +99,38 @@ app.add_middleware(
allow_headers=config['cors']['allow_headers'], allow_headers=config['cors']['allow_headers'],
) )
# Global session registry: global_session_id -> {user_id, local_session_id, created_at} # ---------------------------------------------------------------------------
# Persistent session registry: global_session_id -> {user_id, local_session_id, ...}
# Saved to disk so sessions survive service restarts.
# ---------------------------------------------------------------------------
SESSION_STORE_FILE = os.path.join(os.path.dirname(__file__), "data", "sessions.json")
os.makedirs(os.path.dirname(SESSION_STORE_FILE), exist_ok=True)
global_sessions = {} global_sessions = {}
_sessions_lock = threading.Lock()
def _save_sessions():
"""Persist global_sessions to disk."""
try:
with _sessions_lock:
with open(SESSION_STORE_FILE, 'w') as f:
json_mod.dump(global_sessions, f, indent=2)
except Exception as e:
print(f"WARNING: Failed to save session store: {e}")
def _load_sessions():
"""Load global_sessions from disk."""
global global_sessions
if os.path.exists(SESSION_STORE_FILE):
try:
with open(SESSION_STORE_FILE, 'r') as f:
global_sessions = json_mod.load(f)
print(f"Loaded {len(global_sessions)} sessions from store")
except Exception as e:
print(f"WARNING: Failed to load session store, starting fresh: {e}")
global_sessions = {}
_load_sessions()
def get_real_ip(request: Request) -> str: def get_real_ip(request: Request) -> str:
"""Get the real client IP address, considering X-Forwarded-For header""" """Get the real client IP address, considering X-Forwarded-For header"""
@ -255,6 +314,7 @@ async def create_session(request: SessionCreateRequest):
"hermes_path": user_hermes_path, "hermes_path": user_hermes_path,
"status": "active" "status": "active"
} }
_save_sessions()
return { return {
"session_id": global_session_id, "session_id": global_session_id,
@ -296,8 +356,8 @@ async def send_session_message(session_id: str, request: SessionMessageRequest):
# with Hermes' session system. For now, we'll execute commands directly. # with Hermes' session system. For now, we'll execute commands directly.
# In production, this would interface with Hermes' internal session management. # In production, this would interface with Hermes' internal session management.
# Execute the message as a command # Execute the message as a command using non-interactive mode
command_args = ["chat", request.message] command_args = ["chat", "-q", request.message, "--source", "tool"]
result = await execute_hermes_command( result = await execute_hermes_command(
command_args, command_args,
@ -331,9 +391,10 @@ async def execute_hermes_command(command_args, user_id=None, timeout=300):
hermes_dot_path = os.path.join(user_base_path, ".hermes") hermes_dot_path = os.path.join(user_base_path, ".hermes")
else: else:
user_hermes_path = BASE_HERMES_PATH user_hermes_path = BASE_HERMES_PATH
hermes_dot_path = "/d/hermesai/.hermes" hermes_dot_path = HERMES_HOME
python_path = "/d/hermesai/.hermes/hermes-agent/.venv/bin/python3" # Resolve Python interpreter path dynamically
python_path = os.path.join(BASE_HERMES_PATH, ".venv", "bin", "python3")
cmd = [python_path, "-m", "hermes_cli.main"] + command_args cmd = [python_path, "-m", "hermes_cli.main"] + command_args
env = os.environ.copy() env = os.environ.copy()