diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..520ce09 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +data/ +__pycache__/ +*.pyc +*.pyo +.venv/ diff --git a/README.md b/README.md index ebbbd3c..4716b48 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,258 @@ -# Hermes Service Web Application +# Hermes Service API -## Overview -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. +Hermes Service 是一个将 Hermes Agent CLI 能力封装为 REST API 的中间件服务。支持多用户隔离、会话持久化、IP/API Key 安全认证。 -## Features -- Execute Hermes CLI commands via API -- Manage multiple Hermes service instances -- User and organization-based access control (using rbac module) -- Real-time communication via WebSocket -- Configuration management +**版本**: v1.3.0 +**运行端口**: 默认 9123 (可通过 config.yaml 修改) -## 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) -- `models/`: Database table definitions -- `json/`: CRUD operation definitions -- `init/`: Initialization data \ No newline at end of file +## 启动方式 + +```bash +cd ~/repos/hermes-service +python3 main.py +# 或使用 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: ` | `X-API-Key: 5ftyuvhfhi3345` | +| `bearer` | `Authorization: Bearer ` | `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 --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` 文件中,服务重启后可恢复已有会话。 + +**存储路径**: `/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 路径,替代硬编码路径 | diff --git a/main.py b/main.py index 8fea501..20f1af8 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,8 @@ import os import sys import asyncio import uuid +import threading +import json as json_mod from datetime import datetime from pathlib import Path from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request @@ -19,8 +21,34 @@ import ipaddress import yaml 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 USERS_BASE = "/d/hermesai/users" @@ -38,7 +66,8 @@ else: 'allowed_ips': ['127.0.0.1', '::1'], 'enable_api_key': False, 'api_keys': [], - 'api_key_header': 'X-API-Key' + 'api_key_header': 'X-API-Key', + 'auth_method': 'header' # 'header' or 'bearer' }, 'nginx': { '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']}") -app = FastAPI(title="Hermes Service API", version="1.2.0") +app = FastAPI(title="Hermes Service API", version="1.3.0") # Configure CORS app.add_middleware( @@ -70,8 +99,38 @@ app.add_middleware( 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 = {} +_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: """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, "status": "active" } + _save_sessions() return { "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. # In production, this would interface with Hermes' internal session management. - # Execute the message as a command - command_args = ["chat", request.message] + # Execute the message as a command using non-interactive mode + command_args = ["chat", "-q", request.message, "--source", "tool"] result = await execute_hermes_command( 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") else: user_hermes_path = BASE_HERMES_PATH - hermes_dot_path = "/d/hermesai/.hermes" - - python_path = "/d/hermesai/.hermes/hermes-agent/.venv/bin/python3" + hermes_dot_path = HERMES_HOME + + # 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 env = os.environ.copy()