From b1e95882c3f12ca81580ebae0c07821c1fcd48c0 Mon Sep 17 00:00:00 2001 From: nikita42 <13642481+rekurt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:32:19 +0300 Subject: [PATCH 1/5] feat!: migrate to Hermes Agent (v2.0.0) BREAKING CHANGE: all instances now run NousResearch Hermes Agent instead of OpenClaw. Fresh-install cutover: no data/session import, workspaces are recreated from templates. - images: Dockerfile.user / admin/Dockerfile on nousresearch/hermes-agent:stable - compose: admin builds locally, hermes_data -> /opt/data, dashboard 9119 (port map 18789 / 18790+N preserved), HERMES_UID/GID=1000 - configs: instances/_template/hermes_config.yaml.template + admin/config.yaml (quota-proxy provider, api_mode anthropic_messages, voice.stt openai) - scripts: add-user.sh rewritten for hermes_data/.env scheme (TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, HERMES_GATEWAY_TOKEN); backup/fix-permissions/autoupdate/migrate-instances updated; refresh-oauth.py and update-token.py removed (OAuth lives in CLIProxyAPI) - legacy cleanup: openclaw.json templates, legacy workspace migrations and all openclaw/clawdbot/moltbot references removed (grep = 0) - docs: README x2, CLAUDE.md, docs/SECURITY.md rebranded; migration plan in docs/migration-hermes.md --- .github/scripts/ai_security_check.py | 2 +- .gitignore | 11 +- CLAUDE.md | 32 +++-- README.md | 10 +- README.ru.md | 10 +- VERSION | 2 +- docker-compose.yml | 19 ++- docs/SECURITY.md | 2 +- docs/migration-hermes.md | 134 ++++++++++++++++++ instances/Dockerfile.user | 14 +- .../_template/hermes_config.yaml.template | 24 ++++ instances/_template/openclaw.json.template | 88 ------------ instances/_template/workspace/SOUL.md | 2 +- instances/_template/workspace/TOOLS.md | 2 +- instances/admin/Dockerfile | 6 +- instances/admin/config.yaml | 15 ++ instances/admin/openclaw.json | 51 ------- instances/admin/workspace/AGENTS.md | 32 ++--- instances/admin/workspace/TOOLS.md | 2 +- migrations/004_whisper_skill.py | 34 ----- migrations/015_dadata_mcp.py | 37 ----- migrations/016_remove_mcp_servers.py | 17 --- migrations/021_cleanup_session_key.py | 27 ---- migrations/022_update_admin_agents.py | 118 --------------- migrations/README.md | 2 +- quota-proxy/proxy.py | 2 +- scripts/add-user.sh | 51 ++++--- scripts/autoupdate.sh | 2 +- scripts/backup.sh | 2 +- scripts/fix-permissions.sh | 2 +- scripts/migrate-instances.py | 14 +- scripts/refresh-oauth.py | 75 ---------- scripts/update-token.py | 121 ---------------- .../skills/admin-dashboard/KNOWLEDGE.md | 10 +- service-agent/skills/compliance/enrich.py | 2 +- service-agent/skills/qmd/run.py | 4 +- shared/skills/qmd/SKILL.md | 2 +- shared/skills/yandex-oauth/SKILL.md | 2 +- .../yandex-oauth/references/calendar.md | 2 +- .../skills/yandex-oauth/references/tracker.md | 2 +- 40 files changed, 296 insertions(+), 690 deletions(-) create mode 100644 docs/migration-hermes.md create mode 100644 instances/_template/hermes_config.yaml.template delete mode 100644 instances/_template/openclaw.json.template create mode 100644 instances/admin/config.yaml delete mode 100644 instances/admin/openclaw.json delete mode 100644 migrations/004_whisper_skill.py delete mode 100644 migrations/015_dadata_mcp.py delete mode 100644 migrations/016_remove_mcp_servers.py delete mode 100644 migrations/021_cleanup_session_key.py delete mode 100644 migrations/022_update_admin_agents.py delete mode 100755 scripts/refresh-oauth.py delete mode 100755 scripts/update-token.py diff --git a/.github/scripts/ai_security_check.py b/.github/scripts/ai_security_check.py index 19f8721..46220a7 100644 --- a/.github/scripts/ai_security_check.py +++ b/.github/scripts/ai_security_check.py @@ -154,7 +154,7 @@ def looks_text(path: pathlib.Path) -> bool: return False def should_skip_secret_path(path: pathlib.Path) -> bool: - skip_parts = {".git", ".venv", "__pycache__", "node_modules", "openclaw_data", ".openclaw"} + skip_parts = {".git", ".venv", "__pycache__", "node_modules", "hermes_data", ".hermes"} return bool(skip_parts.intersection(path.parts)) def check_hardcoded_tokens(): diff --git a/.gitignore b/.gitignore index 0b5d03c..013faf4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,7 @@ docker-compose.override.yml .cli-proxy-api/ Codex-*.json instances/*/.env -instances/*/openclaw.json -!instances/admin/openclaw.json -instances/*/openclaw_data/ +instances/*/hermes_data/ **/*.env **/*.key **/*.token @@ -40,6 +38,10 @@ backups/ .DS_Store Thumbs.db +# Рантайм-директории платформы (локальные, не для git) +.arkenos/ +.optimus/ + # ralphex progress logs .ralphex/progress/ /docs/plans/* @@ -49,12 +51,11 @@ shared/skills/corp-greeting/.greeting_counter # Migration state per instance (local) instances/*/.migrations_applied -instances/*/openclaw_data/.migrations_applied # Instance workspace runtime data (not tracked) instances/*/workspace/memory/ instances/*/workspace/MEMORY.md -instances/*/workspace/.openclaw/ +instances/*/workspace/.hermes/ instances/*/workspace/.git/ instances/*/workspace/.gitignore instances/*/workspace/notes-vault/ diff --git a/CLAUDE.md b/CLAUDE.md index 6a4e149..004004a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -**CortexForge** — корпоративная AI-инфраструктура на базе [OpenClaw](https://github.com/openclaw/openclaw): каждому сотруднику выдаётся изолированный Telegram-бот с собственной памятью, квотой токенов и персонажем. Разворачивается одной командой на одном сервере через Docker Compose. +**CortexForge** — корпоративная AI-инфраструктура на базе [Hermes Agent](https://github.com/NousResearch/hermes-agent): каждому сотруднику выдаётся изолированный Telegram-бот с собственной памятью, квотой токенов и персонажем. Разворачивается одной командой на одном сервере через Docker Compose. ## Commands @@ -160,13 +160,13 @@ python3 scripts/migrate-instances.py --dry-run # посмотреть что Инстансы **никогда** не видят настоящий API-ключ. Цепочка: 1. `add-user.sh` генерирует `QUOTA_KEY_=quota--` в корневом `.env` -2. В `docker-compose.override.yml` инстанс получает `ANTHROPIC_API_KEY=${QUOTA_KEY_}` — OpenClaw думает, что это обычный API-ключ -3. `openclaw.json.template` указывает `baseUrl: "http://quota-proxy:9090"` — все запросы идут через прокси +2. В `docker-compose.override.yml` инстанс получает `ANTHROPIC_API_KEY=${QUOTA_KEY_}` — Hermes думает, что это обычный API-ключ +3. `hermes_config.yaml.template` указывает `base_url: http://quota-proxy:9090` — все запросы идут через прокси 4. `quota-proxy` хэширует полученный ключ (SHA256), ищет в `_quota_keys`, подставляет настоящий upstream-ключ ### User instance image -Пользовательские инстансы собираются из `instances/Dockerfile.user` (не напрямую из `ghcr.io/openclaw/openclaw:latest`). Поверх базового образа добавлены: `ffmpeg` (транскрибация аудио), `tesseract-ocr` (русский + английский), `mcporter` (MCP CLI). Admin использует базовый образ напрямую. +Пользовательские инстансы собираются из `instances/Dockerfile.user` (не напрямую из `nousresearch/hermes-agent:stable`). Поверх базового образа добавлены: `ffmpeg` (транскрибация аудио), `tesseract-ocr` (русский + английский), `mcporter` (MCP CLI). Admin собирается из `instances/admin/Dockerfile` (тот же базовый образ + docker CLI). ### Service-agent skills @@ -218,7 +218,7 @@ Naming convention: сервис `assistant-`, контейнер `corp-/workspace/ SOUL.md # персонаж и стиль общения @@ -230,7 +230,10 @@ instances//workspace/ HEARTBEAT.md # задачи для периодических проверок (пустой = skip) memory/ # долгосрочная память .migrations_applied # трекинг миграций (gitignored) -openclaw.json # конфиг: Telegram-канал + модель + +instances//hermes_data/ # маунт → /opt/data (~/.hermes) + config.yaml # конфиг Hermes: модель (quota-proxy), terminal.cwd + .env # секреты: Telegram, gateway-токен, quota-key ``` ### Admin instance (Admin) @@ -290,10 +293,13 @@ openclaw.json # конфиг: Telegram-канал + модель - `BROKER_KEY_MONITOR` — ключ resource-monitor для отправки алертов в broker - `SERVICE_API_KEY` — ключ для авторизации запросов к service-agent API -Переменные инстанса в `instances//.env`: -- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOW_FROM` +Переменные инстанса в `instances//.env` (compose env_file, для скиллов): - `BROKER_KEY` — берётся из корневого `.env` -- `GATEWAY_TOKEN` — auth-токен OpenClaw web UI (генерируется `add-user.sh`) + +Секреты Hermes в `instances//hermes_data/.env`: +- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS` +- `HERMES_GATEWAY_TOKEN` — auth-токен gateway (генерируется `add-user.sh`) +- `ANTHROPIC_API_KEY` — quota-key для quota-proxy Пороги алертов resource-monitor (env vars): `ALERT_CPU_PCT` (80%), `ALERT_RAM_PCT` (85%), `ALERT_DISK_PCT` (90%), `ALERT_QUOTA_PCT` (80%), `MONITOR_INTERVAL` (60s). @@ -308,8 +314,8 @@ openclaw.json # конфиг: Telegram-канал + модель | 8317 | cliproxyapi | internal only | | 9090 | quota-proxy | `127.0.0.1:9090` | | 9091 | resource-monitor | `127.0.0.1:9091` | -| 18789 | admin OpenClaw UI | `127.0.0.1:18789` | -| 18790+N | user instance UIs | `127.0.0.1:18790+N` | +| 18789 | admin Hermes Dashboard (контейнер: 9119) | `127.0.0.1:18789` | +| 18790+N | user Hermes Dashboards (контейнер: 9119) | `127.0.0.1:18790+N` | ### Key operational scripts @@ -330,9 +336,9 @@ openclaw.json # конфиг: Telegram-канал + модель - **Broker sender auth**: отправитель определяется по API-ключу, не по полю body — подменить sender невозможно - **Streaming не трекается квотой**: `quota-proxy` считает токены только из non-streaming ответов (`{"usage": {...}}`). SSE-стримы (`text/event-stream`) пропускаются — использование не учитывается - **Broker per-operation connections**: `broker.py` открывает/закрывает SQLite-соединение на каждую операцию (для WAL mode). `quota-proxy` наоборот — держит один `_conn` на всё время жизни -- **Node.js heap**: admin = `--max-old-space-size=1536` (1.5 GB), user instances = `768` (768 MB) +- **Memory limits**: admin = 2G, user instances = 1G (`deploy.resources.limits`) - **Test import order**: env-переменные **ОБЯЗАТЕЛЬНО** ставить до `import` тестируемого модуля — модули читают env при импорте, не при вызове - **docker-compose.override.yml**: gitignored, содержит все пользовательские инстансы. `add-user.sh` дописывает туда. Базовый `docker-compose.yml` — только инфраструктура + admin -- **GATEWAY_TOKEN**: генерируется `add-user.sh` для каждого инстанса, используется как auth-токен OpenClaw web UI +- **HERMES_GATEWAY_TOKEN**: генерируется `add-user.sh` для каждого инстанса, используется как auth-токен Hermes gateway - **Migration overrides**: `../overrides/migrations/` — внешние миграции с приоритетом над `migrations/` (совпадение по имени файла) - **ai_security_check.py exit codes**: CRITICAL/HIGH = exit 1 (блокирует CI), MEDIUM/LOW = warnings (не блокируют) diff --git a/README.md b/README.md index c8c9666..54f1f5d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ [![CI Security](https://github.com/example-org/cortex-forge/actions/workflows/security.yml/badge.svg)](https://github.com/example-org/cortex-forge/actions/workflows/security.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE) -CortexForge is an experimental AI-assistant infrastructure sandbox built on [OpenClaw](https://github.com/openclaw/openclaw). +CortexForge is an experimental AI-assistant infrastructure sandbox built on [Hermes Agent](https://github.com/NousResearch/hermes-agent). [Project website](https://rekurt.github.io/cortex-forge/) · [All projects by rekurt](https://rekurt.github.io/projects/) @@ -46,7 +46,7 @@ CortexForge is split into a few small services: | Component | Description | |---|---| -| **OpenClaw instances** | One admin instance plus optional generated user instances | +| **Hermes instances** | One admin instance plus optional generated user instances | | **CLIProxyAPI** | Optional OAuth proxy for Codex/Claude Max style access; stores its own OAuth session in a Docker volume | | **quota-proxy** | Quota, rate-limit and audit proxy in front of the upstream model endpoint | | **message-broker** | SQLite-backed inbox/message bus between instances | @@ -63,7 +63,7 @@ The project started as a playful way to see what an "AI office full of little pe |---|---| | Per-instance context | Separate workspace, memory, Telegram token, and persona files | | Quota accounting | `quota-proxy` stores monthly counters in SQLite | -| Admin control plane | Admin OpenClaw instance mounts the repo as `/infra` and can inspect Docker | +| Admin control plane | Admin Hermes instance mounts the repo as `/infra` and can inspect Docker | | Bot-to-bot messaging | `message-broker` exposes `/send`, `/inbox`, and admin inbox views | | Skill execution | `service-agent` loads skill manifests and runs allowlisted subprocesses | @@ -96,7 +96,7 @@ The project started as a playful way to see what an "AI office full of little pe │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ └─────────────┼─────────────┘ │ -│ │ openclaw.json baseUrl=http://quota-proxy │ +│ │ config.yaml base_url=http://quota-proxy │ │ ┌────────▼─────────┐ │ │ │ CortexForge │ corp-internal │ │ │ Proxy :9090 │ corp-admin │ @@ -125,7 +125,7 @@ The project started as a playful way to see what an "AI office full of little pe ### How a request flows 1. Employee sends a message to their Telegram bot -2. OpenClaw inside the container receives it and calls the provider configured in `openclaw.json`; user templates point `baseUrl` to `http://quota-proxy:9090` +2. Hermes inside the container receives it and calls the provider configured in `config.yaml`; user templates point `base_url` to `http://quota-proxy:9090` 3. `quota-proxy` checks the token quota, updates the counter in SQLite, then forwards the request to CLIProxyAPI by default (`http://cliproxyapi:8317`) or to a direct upstream if configured 4. If the quota is exceeded, `quota-proxy` returns HTTP 429 immediately — no real API call is made diff --git a/README.ru.md b/README.ru.md index 0639b21..4d57c9e 100644 --- a/README.ru.md +++ b/README.ru.md @@ -13,7 +13,7 @@ [![CI Security](https://github.com/example-org/cortex-forge/actions/workflows/security.yml/badge.svg)](https://github.com/example-org/cortex-forge/actions/workflows/security.yml) [![License: MIT](https://img.shields.io/badge/лицензия-MIT-green)](LICENSE) -CortexForge — экспериментальная песочница AI-ассистентов на базе [OpenClaw](https://github.com/openclaw/openclaw). +CortexForge — экспериментальная песочница AI-ассистентов на базе [Hermes Agent](https://github.com/NousResearch/hermes-agent). Идея простая: поднять на одном хосте несколько изолированных Telegram-ботов, у каждого — свой workspace, персонаж, квота токенов и inbox. @@ -44,7 +44,7 @@ CortexForge состоит из нескольких небольших серв | Компонент | Описание | |---|---| -| **OpenClaw-инстансы** | Один admin-инстанс и опциональные сгенерированные user-инстансы | +| **Hermes-инстансы** | Один admin-инстанс и опциональные сгенерированные user-инстансы | | **CLIProxyAPI** | Опциональный OAuth-прокси для Codex/Claude Max-like доступа; хранит OAuth-сессию в Docker volume | | **quota-proxy** | Квоты, rate-limit и аудит перед upstream model endpoint | | **message-broker** | SQLite-backed inbox/шина сообщений между инстансами | @@ -61,7 +61,7 @@ CortexForge состоит из нескольких небольших серв |---|---| | Контекст на инстанс | Отдельные workspace, memory, Telegram token и persona files | | Квотирование | `quota-proxy` хранит месячные счётчики в SQLite | -| Admin control plane | Admin OpenClaw-инстанс монтирует repo как `/infra` и видит Docker | +| Admin control plane | Admin Hermes-инстанс монтирует repo как `/infra` и видит Docker | | Сообщения между ботами | `message-broker` даёт `/send`, `/inbox` и admin inbox views | | Skill execution | `service-agent` читает manifests и запускает allowlisted subprocesses | @@ -94,7 +94,7 @@ CortexForge состоит из нескольких небольших серв │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ └─────────────┼─────────────┘ │ -│ │ openclaw.json baseUrl=http://quota-proxy │ +│ │ config.yaml base_url=http://quota-proxy │ │ ┌────────▼─────────┐ │ │ │ quota-proxy │ corp-internal │ │ │ :9090 │ corp-admin │ @@ -123,7 +123,7 @@ CortexForge состоит из нескольких небольших серв ### Как проходит запрос 1. Сотрудник отправляет сообщение своему Telegram-боту -2. OpenClaw внутри контейнера обращается к провайдеру из `openclaw.json`; user template указывает `baseUrl` на `http://quota-proxy:9090` +2. Hermes внутри контейнера обращается к провайдеру из `config.yaml`; user template указывает `base_url` на `http://quota-proxy:9090` 3. `quota-proxy` проверяет квоту токенов, обновляет счётчик в SQLite, затем по умолчанию форвардит запрос в CLIProxyAPI (`http://cliproxyapi:8317`) или в direct upstream, если он настроен отдельно 4. Если квота исчерпана — `quota-proxy` немедленно возвращает HTTP 429, реальный вызов не делается diff --git a/VERSION b/VERSION index a918a2a..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +2.0.0 diff --git a/docker-compose.yml b/docker-compose.yml index 1a9e1b2..b644c3e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -112,18 +112,21 @@ services: # Подключён к corp-admin (для quota API) + corp-internal (для broker) # ════════════════════════════════════════════════════ assistant-admin: - image: ghcr.io/openclaw/openclaw:latest + build: + context: . + dockerfile: instances/admin/Dockerfile + image: cortex-forge-admin:latest container_name: corp-admin restart: unless-stopped env_file: instances/admin/.env volumes: - - ./instances/admin/openclaw_data:/home/node/.openclaw - - ./instances/admin/openclaw_data/ssh:/home/node/.ssh + - ./instances/admin/hermes_data:/opt/data + - ./instances/admin/config.yaml:/opt/data/config.yaml:ro + - ./instances/admin/workspace:/opt/data/workspace + - ./instances/admin/hermes_data/ssh:/home/hermes/.ssh - ./shared/skills:/shared/skills - ./shared/docs:/shared/docs - ./shared/compliance-data:/shared/compliance-data - - ./instances/admin/workspace:/home/node/.openclaw/workspace - - ./instances/admin/openclaw.json:/home/node/.openclaw/openclaw.json # Весь проект (rw) — admin уже имеет ANTHROPIC_API_KEY через environment - ./:/infra/ - ../:/infra-parent/ # доступ к внешним данным @@ -137,9 +140,11 @@ services: - QUOTA_ADMIN_TOKEN=${QUOTA_ADMIN_TOKEN} - WORKSPACE_PARENT=/infra-parent # для sync-instances.sh и migrate-instances.py - SERVICE_API_KEY=${SERVICE_API_KEY} - - NODE_OPTIONS=--max-old-space-size=1536 + - HERMES_UID=1000 + - HERMES_GID=1000 + - HERMES_DASHBOARD=1 ports: - - "127.0.0.1:18789:18789" # OpenClaw Control UI — http://localhost:18789 + - "127.0.0.1:18789:9119" # Dashboard — http://localhost:18789 networks: - corp-internal - corp-admin diff --git a/docs/SECURITY.md b/docs/SECURITY.md index fe6c4e7..639758d 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -17,7 +17,7 @@ We will credit you in the release notes unless you prefer to stay anonymous. ### Scope In-scope: `quota-proxy`, `broker`, `service-agent`, `resource-monitor`, `scripts/`, Docker networking, auth mechanisms. -Out-of-scope: OpenClaw itself, Anthropic API, third-party GitHub Actions. +Out-of-scope: Hermes itself, Anthropic API, third-party GitHub Actions. --- diff --git a/docs/migration-hermes.md b/docs/migration-hermes.md new file mode 100644 index 0000000..4b72dd5 --- /dev/null +++ b/docs/migration-hermes.md @@ -0,0 +1,134 @@ +# Миграция CortexForge 2.0: полный переход на Hermes Agent (fresh install) + +> Статус: план (v3, реализация) · Дата: 2026-10-25 +> +> **Стратегия:** полный переход одной новой версией **v2.0.0**. Никакого импорта старых +> данных и никаких бэкап-церемоний — все инстансы стартуют на Hermes с **чистыми конфигами +> и чистыми воркспейсами**. Персоны, скиллы и правила живут в репозитории и пересоздаются +> из шаблонов; старые сессии/память не переносим (объявляем в релизе как breaking change). +> +> **Принцип нулевого легаси:** в v2.0 в живом коде, конфигах, скриптах и документации +> не остаётся ни одной ссылки на OpenClaw / Clawdbot / Moltbot, старые данные и образы +> удаляются при деплое. Исторический `CHANGELOG.md` — единственное исключение. + +--- + +## 1. Цель + +CortexForge переезжает с [OpenClaw](https://github.com/openclaw/openclaw) на +[Hermes Agent](https://hermes-agent.nousresearch.com) (NousResearch). Результат: + +- все инстансы (admin + пользовательские) работают на Hermes в релизе v2.0.0; +- `grep -ri "openclaw|clawdbot|moltbot"` по репо (без CHANGELOG и этого документа) → **0**; +- ни одного OpenClaw-контейнера/образа/каталога данных на хосте. + +## 2. Целевая архитектура (факты по Hermes) + +| Что | Значение | +|---|---| +| Образ | `nousresearch/hermes-agent` (Docker Hub, amd64/arm64) | +| Запуск | `command: ["gateway", "run"]`, ENTRYPOINT `/init` (s6-overlay) — не переопределять | +| Данные | том `…:/opt/data` = `~/.hermes`: `.env`, `config.yaml`, `SOUL.md`, `sessions/`, `memories/`, `skills/`, `home/`, `logs/` | +| Права | env `HERMES_UID`/`HERMES_GID` — s6 ремапит внутреннего пользователя; у нас `1000:1000` (как раньше `node`) | +| Dashboard | порт `9119` (`dashboard --host 127.0.0.1 --no-open`); в контейнере с gateway включается `HERMES_DASHBOARD=1` | +| Gateway API | порт `8642` (OpenAI-совместимый API + health) — нужен для dashboard/внешних инструментов | +| Telegram | env `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS` (через запятую) | +| Gateway-токен | env `HERMES_GATEWAY_TOKEN` | +| Модель | `config.yaml` → `model: {provider, default, base_url, api_mode}`; `api_mode: anthropic_messages` для совместимого эндпоинта | +| Переменные | в `config.yaml` работает подстановка `${VAR}` из окружения | +| Рабочая папка | `terminal.cwd`; сэндбокс — `terminal.backend` / `terminal.docker_image` | +| MCP | `mcp_servers.*` (у нас MCP не используются) | + +### Схема инстанса + +``` +instances// + hermes_data/ # маунт → /opt/data (config.yaml, .env, SOUL.md, memories/, …) + workspace/ # маунт → /opt/data/workspace, terminal.cwd + .env # секреты инстанса (compose env_file) +``` + +Порты dashboard: `127.0.0.1:18790+N:9119` (схема локальных портов сохранена). + +## 3. Инвентаризация легаси (что зачищаем в v2.0) + +`grep -ri "openclaw|clawdbot|moltbot"` по репо: + +| Файл | Вхождений | Действие | +|---|---|---| +| `docker-compose.yml` | 6 | образ/маунты/конфиг admin → Hermes | +| `instances/Dockerfile.user`, `instances/admin/Dockerfile` | 1+1 | базовый образ → Hermes + те же слои | +| `instances/admin/openclaw.json`, `_template/openclaw.json.template` | — | заменить на `config.yaml` (+удалить старые) | +| `scripts/add-user.sh` | 10 | переписать под hermes_data/config.yaml | +| `scripts/backup.sh` | 1 | exclude → `hermes_data/sessions` | +| `scripts/fix-permissions.sh` | 1 | `openclaw.json` → `hermes_data/config.yaml` | +| `scripts/migrate-instances.py` | 4 | убрать legacy-fallback `openclaw_data/workspace` | +| `scripts/refresh-oauth.py`, `scripts/update-token.py` | 3+3 | **удалить** — OpenClaw-внутренняя OAuth-механика (Claude Max OAuth живёт в CLIProxyAPI) | +| `scripts/autoupdate.sh` | 1 | pull `nousresearch/hermes-agent` | +| `migrations/004,015,016,021,022 + README` | ~24 | удалить (воркспейсы создаются заново из шаблона) | +| `.gitignore` | 5 | новые игноры | +| `CLAUDE.md`, `README.md`, `README.ru.md`, `docs/SECURITY.md` | 9+5+5+1 | ребрендинг | +| `service-agent/skills/qmd/run.py`, `compliance/enrich.py`, `admin-dashboard/KNOWLEDGE.md` | 2+1+5 | пути/упоминания | +| `shared/skills/qmd/SKILL.md`, `yandex-oauth/*` | 1+3 | упоминания | +| `.github/scripts/ai_security_check.py` | 1 | `skip_parts` → `hermes_data`/`.hermes` | +| `quota-proxy/proxy.py` | 1 | упоминание | +| `CHANGELOG.md` | 1 | **не трогаем** | + +## 4. Этапы реализации + +1. **Образы и compose** — `Dockerfile.user`, `admin/Dockerfile`, `docker-compose.yml` (этот PR). +2. **Шаблоны конфигов** — `_template/hermes_config.yaml.template`, `admin/config.yaml`, схема `.env`. +3. **Скрипты** — `add-user.sh` и остальные по таблице §3. +4. **Зачистка** — `.gitignore`, CI, skills, migrations, `quota-proxy`. +5. **Документация** — README ×2, `CLAUDE.md`, `docs/SECURITY.md`. +6. **Верификация** — grep → 0, `docker compose config` валиден, `VERSION` → 2.0.0. + +## 5. Риски + +| # | Риск | Митигация | +|---|---|---| +| R1 | Мульти-провайдер (anthropic через quota-proxy + openai whisper) | Проверить, что `model.base_url` + второй провайдер работают одновременно | +| R2 | Потеря старых сессий/памяти | Осознанное решение (fresh install); объявить в релизе | +| R3 | Порт/healthcheck отличаются от OpenClaw | **Решено:** dashboard 9119 / API 8642 (официальный compose Hermes) | +| R4 | `TELEGRAM_ALLOWED_USERS`-семантика отличается от `allowFrom` | **Решено:** числовые ID через запятую, deny-by-default — семантика совпадает | +| R5 | Квоты: Hermes должен слать `ANTHROPIC_API_KEY=$QUOTA_KEY_*` в quota-proxy | Проверить логи quota-proxy после деплоя | +| R6 | Whisper/голосовые: как Hermes конфигурирует аудио-транскрипцию | **Решено:** `voice.stt.provider` в `config.yaml` (`openai` / `local`); добавлено в шаблон | + +## 6. Чек-лист верификации (на каждый инстанс после деплоя) + +- [ ] Контейнер стартует, gateway под s6 живой (`docker logs`) +- [ ] `hermes status` внутри контейнера — провайдер авторизован +- [ ] Telegram: сообщение боту → ответ +- [ ] Запрос виден в логах quota-proxy, токены считаются в квоту +- [ ] Персона (SOUL.md) применилась +- [ ] Скиллы из workspace/shared подключены, mcporter работает +- [ ] Голосовое → транскрипция +- [ ] Broker: `/send` между инстансами работает +- [ ] Dashboard на `127.0.0.1:18790+N` открывается + +## 7. Rollback + +`git checkout <прошлый тег> && docker compose up -d` — код откатывается, данные не переносились, +поэтому откат означает старт с чистыми старыми воркспейсами. Квоты в quota-proxy (SQLite) +не зависят от агента и остаются. + +## 8. Оценка + +| Этап | Оценка | +|---|---| +| Реализация в репо (этапы 1–5) | 2–3 дня | +| Деплой и верификация | 0,5 дня | +| **Итого** | **~3 дня** (простой инстансов: часы, а не дни) | + +## 9. Открытые вопросы — закрыты (проверено по докам и Docker Hub registry) + +- **Q1** OAuth Claude Max: живёт в CLIProxyAPI (volume `cliproxyapi-auths`); инстансы ходят + через quota-proxy с quota-key. `refresh-oauth.py`/`update-token.py` удалены как легаси. +- **Q2** Тег образа: `nousresearch/hermes-agent:stable` существует (не только `-desktop`) — + проверено через registry API. +- **Q3** Голосовые: транскрибируются gateway автоматически; STT — `voice.stt.provider` + в `config.yaml` (`openai` / `local` faster-whisper). В шаблон добавлен `openai`. +- **Q4** Сессии: `~/.hermes/sessions/` → exclude в `backup.sh` верный. +- **Q5** Порты: dashboard `9119`, gateway API `8642`. +- **Q6** Telegram: `TELEGRAM_BOT_TOKEN` + `TELEGRAM_ALLOWED_USERS` (числовые ID через запятую) + в `~/.hermes/.env`; deny-by-default — семантика соответствует старому `allowFrom`. diff --git a/instances/Dockerfile.user b/instances/Dockerfile.user index 68f6805..982712e 100644 --- a/instances/Dockerfile.user +++ b/instances/Dockerfile.user @@ -1,15 +1,17 @@ -FROM ghcr.io/openclaw/openclaw:latest +FROM nousresearch/hermes-agent:stable -# ffmpeg — для транскрипции голосовых (openai-whisper-api) -# mcporter — MCP CLI для вызова внешних инструментов (DaData и др.) -# tesseract-ocr — распознавание текста на изображениях (OCR) +# ffmpeg — для транскрипции голосовых (whisper) +# tesseract-ocr — распознавание текста на изображениях (OCR, rus+eng) +# nodejs/npm + mcporter — MCP CLI для вызова внешних инструментов (DaData и др.) USER root RUN apt-get update && \ apt-get install -y --no-install-recommends \ ffmpeg \ tesseract-ocr \ tesseract-ocr-rus \ - tesseract-ocr-eng && \ + tesseract-ocr-eng \ + nodejs npm && \ apt-get clean && rm -rf /var/lib/apt/lists/* && \ npm install -g mcporter -USER node +# Пользователь внутри контейнера маппится на этапе запуска +# через HERMES_UID/HERMES_GID (s6-overlay stage2), USER в конце не ставим. diff --git a/instances/_template/hermes_config.yaml.template b/instances/_template/hermes_config.yaml.template new file mode 100644 index 0000000..be6f7f7 --- /dev/null +++ b/instances/_template/hermes_config.yaml.template @@ -0,0 +1,24 @@ +# Конфигурация Hermes Agent — шаблон пользовательского инстанса {{NAME}} +# Маунтится в /opt/data/config.yaml (том hermes_data → ~/.hermes) +# Секреты лежат рядом в /opt/data/.env: +# TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, HERMES_GATEWAY_TOKEN, +# ANTHROPIC_API_KEY (= QUOTA_KEY_ — ключ quota-proxy), OPENAI_API_KEY + +model: + provider: quota-proxy # кастомный провайдер — локальный quota-proxy + default: claude-sonnet-4-6 + base_url: http://quota-proxy:9090 + api_mode: anthropic_messages + api_key: ${ANTHROPIC_API_KEY} + +# Транскрипция голосовых: STT-провайдер. +# openai — Whisper API через OPENAI_API_KEY (как раньше); +# "local" — faster-whisper бесплатно, без ключа. +voice: + stt: + provider: openai + +terminal: + cwd: /opt/data/workspace + +timezone: Europe/Moscow diff --git a/instances/_template/openclaw.json.template b/instances/_template/openclaw.json.template deleted file mode 100644 index 6062416..0000000 --- a/instances/_template/openclaw.json.template +++ /dev/null @@ -1,88 +0,0 @@ -{ - "agents": { - "defaults": { - "model": { - "primary": "anthropic/claude-sonnet-4-6" - }, - "workspace": "/home/node/.openclaw/workspace", - "compaction": { - "mode": "safeguard" - } - } - }, - "models": { - "providers": { - "anthropic": { - "baseUrl": "http://quota-proxy:9090", - "api": "anthropic-messages", - "apiKey": "${ANTHROPIC_API_KEY}", - "models": [ - { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "api": "anthropic-messages", - "contextWindow": 200000, - "maxTokens": 16000 - } - ] - }, - "openai": { - "baseUrl": "https://api.openai.com/v1", - "apiKey": "${OPENAI_API_KEY}", - "api": "openai-completions", - "models": [ - { - "id": "whisper-1", - "name": "Whisper" - } - ] - } - } - }, - "channels": { - "telegram": { - "enabled": true, - "botToken": "{{BOT_TOKEN}}", - "allowFrom": [ - {{TG_ID}} - ], - "dmPolicy": "pairing", - "streaming": "off", - "actions": { - "reactions": true, - "sendMessage": true, - "deleteMessage": false - }, - "reactionLevel": "minimal" - } - }, - "gateway": { - "mode": "local", - "port": 18789, - "auth": { - "mode": "token", - "token": "${GATEWAY_TOKEN}" - } - }, - "commands": { - "native": "auto", - "nativeSkills": "auto" - }, - "tools": { - "media": { - "audio": { - "enabled": true, - "models": [ - { - "provider": "openai", - "model": "whisper-1", - "capabilities": [ - "audio" - ], - "type": "provider" - } - ] - } - } - } -} \ No newline at end of file diff --git a/instances/_template/workspace/SOUL.md b/instances/_template/workspace/SOUL.md index a787a53..4a9c88e 100644 --- a/instances/_template/workspace/SOUL.md +++ b/instances/_template/workspace/SOUL.md @@ -103,7 +103,7 @@ _Бананов нет. Есть задачи. И всегда есть спос ## Файловая структура ### Личные файлы (только твои) -- `/home/node/.openclaw/workspace/` — твой воркспейс +- `/opt/data/workspace/` — твой воркспейс - `SOUL.md` — кто ты - `USER.md` — о твоём пользователе - `IDENTITY.md` — имя, роль, emoji diff --git a/instances/_template/workspace/TOOLS.md b/instances/_template/workspace/TOOLS.md index 3655c4b..673233a 100644 --- a/instances/_template/workspace/TOOLS.md +++ b/instances/_template/workspace/TOOLS.md @@ -8,7 +8,7 @@ Помимо общих скиллов (`/shared/skills/`), ты можешь создавать персональные скиллы в своём workspace. -**Где хранятся:** `workspace/skills/` (в контейнере: `/home/node/.openclaw/workspace/skills/`) +**Где хранятся:** `workspace/skills/` (в контейнере: `/opt/data/workspace/skills/`) **Как создать личный скилл:** diff --git a/instances/admin/Dockerfile b/instances/admin/Dockerfile index 9027ade..4cc8515 100644 --- a/instances/admin/Dockerfile +++ b/instances/admin/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/openclaw/openclaw:latest +FROM nousresearch/hermes-agent:stable USER root @@ -15,5 +15,5 @@ RUN apt-get update -qq && \ apt-get update -qq && \ apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin && \ rm -rf /var/lib/apt/lists/* - -USER node +# Пользователь внутри контейнера маппится на этапе запуска +# через HERMES_UID/HERMES_GID (s6-overlay stage2), USER в конце не ставим. diff --git a/instances/admin/config.yaml b/instances/admin/config.yaml new file mode 100644 index 0000000..092e914 --- /dev/null +++ b/instances/admin/config.yaml @@ -0,0 +1,15 @@ +# Конфигурация Hermes Agent — admin +# Маунтится в /opt/data/config.yaml (том hermes_data → ~/.hermes) +# Секреты лежат рядом в /opt/data/.env: +# TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, HERMES_GATEWAY_TOKEN, +# ANTHROPIC_API_KEY (admin ходит напрямую, минуя quota-proxy) + +model: + provider: anthropic + default: claude-sonnet-4-6 + api_mode: anthropic_messages + +terminal: + cwd: /opt/data/workspace + +timezone: Europe/Moscow diff --git a/instances/admin/openclaw.json b/instances/admin/openclaw.json deleted file mode 100644 index 6a03ad0..0000000 --- a/instances/admin/openclaw.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "agents": { - "defaults": { - "model": { - "primary": "anthropic/claude-sonnet-4-6" - }, - "workspace": "/home/node/.openclaw/workspace", - "compaction": { - "mode": "safeguard" - } - } - }, - "channels": { - "telegram": { - "enabled": true, - "botToken": "${TELEGRAM_BOT_TOKEN}", - "allowFrom": [0], - "dmPolicy": "pairing", - "streaming": "off", - "actions": { - "reactions": true, - "sendMessage": true, - "deleteMessage": false - }, - "reactionLevel": "minimal" - } - }, - "gateway": { - "mode": "local", - "port": 18789, - "auth": { - "mode": "token", - "token": "${GATEWAY_TOKEN}" - } - }, - "commands": { - "native": "auto", - "nativeSkills": "auto" - }, - "skills": { - "allowBundled": ["mcporter"], - "entries": { - "mcporter": { - "enabled": true - } - } - }, - "env": { - "PATH": "/home/node/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" - } -} diff --git a/instances/admin/workspace/AGENTS.md b/instances/admin/workspace/AGENTS.md index 03402b4..521c9f6 100644 --- a/instances/admin/workspace/AGENTS.md +++ b/instances/admin/workspace/AGENTS.md @@ -20,7 +20,7 @@ ### Компоненты системы ``` -[OpenClaw Gateway] × N — по одному AI-боту на каждого сотрудника +[Hermes Gateway] × N — по одному AI-боту на каждого сотрудника [quota-proxy] — один корпоративный Anthropic ключ, счётчики токенов [message-broker] — межинстансный мессенджер (изолированные inbox) [resource-monitor] — CPU/RAM/disk метрики @@ -43,7 +43,7 @@ **Что есть у каждого инстанса:** - Собственный Telegram-бот (отдельный токен) -- Собственный volume с данными: `instances//openclaw_data/` +- Собственный volume с данными: `instances//hermes_data/` - Собственный воркспейс: SOUL.md, AGENTS.md, USER.md, MEMORY.md, daily logs, skills - Доступ к общим скиллам: `/shared/skills/` (только чтение!) - Доступ к брокеру сообщений (только свой inbox) @@ -65,7 +65,7 @@ ### Воркспейс агента (у каждого инстанса) ``` -/home/node/.openclaw/workspace/ +/opt/data/workspace/ SOUL.md ← личность (кто я) USER.md ← информация о пользователе AGENTS.md ← протокол работы @@ -79,7 +79,7 @@ ### История сессий (не трогать без причины!) ``` -/home/node/.openclaw/agents/main/sessions/ +/opt/data/sessions/ sessions.json ← индекс всех сессий .jsonl ← история конкретной сессии (compacted контекст!) ``` @@ -92,7 +92,7 @@ docker-compose.yml ← главный compose .env ← секреты проекта instances/ - /openclaw_data/ ← данные инстанса + /hermes_data/ ← данные инстанса _template/ ← шаблон для новых инстансов Dockerfile.user ← образ для user instances (с ffmpeg) shared/skills/ ← общие скиллы (read-only!) @@ -105,7 +105,7 @@ ### ⚠️ Важно: shared/skills смонтирован :ro Скрипты внутри скиллов не могут писать в `/shared/skills/`. -Если скиллу нужен state-файл (кэш, счётчик) → `/home/node/.openclaw/` или `/tmp/` +Если скиллу нужен state-файл (кэш, счётчик) → `/opt/data/` или `/tmp/` --- @@ -123,7 +123,7 @@ **Добавить новый shared скилл:** 1. Создай `/infra/shared/skills//SKILL.md` + нужные скрипты -2. Добавь в таблицу скиллов в шаблоне: `instances/_template/openclaw_data/workspace/AGENTS.md` +2. Добавь в таблицу скиллов в шаблоне: `instances/_template/workspace/AGENTS.md` 3. Создай миграцию `migrations/NNN_add__skill.py` чтобы скилл попал в существующие инстансы --- @@ -169,7 +169,7 @@ cd /infra && python3 scripts/migrate-instances.py | Переменная | Где живёт | Значение | Кто использует | |---|---|---|---| | `ANTHROPIC_API_KEY` (в `.env` корня) | Только в quota-proxy | `sk-ant-xxx` (реальный ключ) | quota-proxy для форвардинга | -| `ANTHROPIC_API_KEY` (в docker env инстанса) | docker-compose.yml | = `QUOTA_KEY_USER_1` (НЕ реальный ключ!) | OpenClaw через openclaw.json | +| `ANTHROPIC_API_KEY` (в docker env инстанса) | docker-compose.yml | = `QUOTA_KEY_USER_1` (НЕ реальный ключ!) | Hermes через config.yaml | | `QUOTA_KEY_USER_1` (в `.env` корня) | docker-compose.yml env mapping | `quota-user-1-xxx` (хэшируется в proxy) | Подставляется как ANTHROPIC_API_KEY | | `QUOTA_ADMIN_TOKEN` | Только Admin и quota-proxy | 32+ символов | Admin API quota-proxy | | `BROKER_KEY_*` | Broker + каждый инстанс | Рандомная строка (хэшируется в broker) | Аутентификация в мессенджере | @@ -197,8 +197,8 @@ cd /infra && python3 scripts/migrate-instances.py | Путь в контейнере | Хост-путь | Права | Кто видит | |---|---|---|---| -| `/home/node/.openclaw/` | `instances//openclaw_data/` | rw | Свой инстанс | -| `/home/node/.openclaw/workspace/` | `instances//workspace/` | rw | Свой инстанс | +| `/opt/data/` | `instances//hermes_data/` | rw | Свой инстанс | +| `/opt/data/workspace/` | `instances//workspace/` | rw | Свой инстанс | | `/shared/skills/` | `shared/skills/` | ro | Все инстансы | | `/shared/docs/` | `shared/docs/` | rw | Все инстансы | | `/shared/compliance-data/` | `shared/compliance-data/` | ro | Все инстансы | @@ -217,7 +217,7 @@ cd /infra && python3 scripts/migrate-instances.py ### Схема потока запроса ``` -1. OpenClaw шлёт запрос на baseUrl (http://quota-proxy:9090) +1. Hermes шлёт запрос на baseUrl (http://quota-proxy:9090) с x-api-key = ANTHROPIC_API_KEY (из env = QUOTA_KEY_*) 2. quota-proxy хэширует ключ через SHA256 3. Сравнивает с хэшами QUOTA_KEY_* из корневого .env (constant-time через hmac.compare_digest) @@ -231,9 +231,9 @@ cd /infra && python3 scripts/migrate-instances.py Это ключевой нюанс, который легко перепутать: -1. `openclaw.json.template` содержит `"apiKey": "${ANTHROPIC_API_KEY}"` +1. `hermes_config.yaml.template` содержит `api_key: ${ANTHROPIC_API_KEY}` 2. Docker Compose маппит: `ANTHROPIC_API_KEY=${QUOTA_KEY_USER_1}` -3. OpenClaw видит env-переменную `ANTHROPIC_API_KEY`, подставляет в apiKey +3. Hermes видит env-переменную `ANTHROPIC_API_KEY`, подставляет в `api_key` 4. Результат: запрос идёт на quota-proxy с quota key, proxy подменяет на реальный **Это значит:** когда ты видишь `ANTHROPIC_API_KEY` в env инстанса — это **НЕ** реальный ключ Anthropic! Это quota-ключ, который quota-proxy распознаёт и подменяет. @@ -278,8 +278,8 @@ make deploy ### Если бот "завис" в старой личности ```bash # Очистить сессии и перезапустить -rm -f /infra/instances//openclaw_data/agents/main/sessions/*.jsonl -rm -f /infra/instances//openclaw_data/agents/main/sessions/sessions.json +rm -f /infra/instances//hermes_data/sessions/*.jsonl +rm -f /infra/instances//hermes_data/sessions/sessions.json docker compose restart corp- ``` @@ -317,7 +317,7 @@ curl -s "http://message-broker:8080/inbox" \ ## ✍️ Write tool — важно про пути -OpenClaw ограничивает `write` только папкой workspace. +Hermes ограничивает `write` только папкой workspace. Чтобы создавать/редактировать файлы проекта — используй пути через `workspace/infra/`: | Что создать | Путь для write tool | diff --git a/instances/admin/workspace/TOOLS.md b/instances/admin/workspace/TOOLS.md index b5e647f..1890ef9 100644 --- a/instances/admin/workspace/TOOLS.md +++ b/instances/admin/workspace/TOOLS.md @@ -55,7 +55,7 @@ cd /infra && python3 scripts/migrate-instances.py --dry-run # посмотрет | Логи контейнера | `docker logs corp-` | | База квот (SQLite) | `quota-data` Docker volume → `/data/quota.db` | | База метрик (SQLite) | `monitor-data` Docker volume → `/data/metrics.db` | -| Данные инстанса | `instances//openclaw_data/` | +| Данные инстанса | `instances//hermes_data/` | | Воркспейс инстанса | `instances//workspace/` | | Shared-скиллы | `shared/skills/` (ro для инстансов) | | Shared-документы | `shared/docs/` (rw для инстансов) | diff --git a/migrations/004_whisper_skill.py b/migrations/004_whisper_skill.py deleted file mode 100644 index 32d6a49..0000000 --- a/migrations/004_whisper_skill.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Добавить openai-whisper-api и openai-image-gen в openclaw.json инстанса.""" -import pathlib -import json - -DESCRIPTION = "add openai-whisper-api + image-gen to openclaw.json" - -def apply(workspace: pathlib.Path): - # openclaw.json лежит на уровень выше workspace - cfg_path = workspace.parent / "openclaw.json" - if not cfg_path.exists(): - return - - raw = cfg_path.read_text() - - # Не трогаем если уже есть - if "openai-whisper-api" in raw: - return - - # Подставляем как строку — файл содержит ${ENV_VAR} плейсхолдеры - skills_block = ''', - "skills": { - "entries": { - "openai-whisper-api": { - "apiKey": "${OPENAI_API_KEY}" - }, - "openai-image-gen": { - "apiKey": "${OPENAI_API_KEY}" - } - } - }''' - - # Вставляем перед последней закрывающей скобкой - new_raw = raw.rstrip().rstrip("}") + skills_block + "\n}" - cfg_path.write_text(new_raw) diff --git a/migrations/015_dadata_mcp.py b/migrations/015_dadata_mcp.py deleted file mode 100644 index 29e87bc..0000000 --- a/migrations/015_dadata_mcp.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Добавить DaData MCP сервер в openclaw.json всех инстансов.""" -import pathlib, json - -DESCRIPTION = "add DaData MCP to all instances — address/company lookup" - -MCP_BLOCK = { - "dadata": { - "command": "npx", - "args": [ - "-y", - "supergateway", - "--streamableHttp", - "https://mcp.dadata.ru/mcp", - "--oauth2Bearer", - "${DADATA_API_KEY}:${DADATA_SECRET}" - ] - } -} - - -def apply(workspace: pathlib.Path): - cfg_path = workspace.parent / "openclaw.json" - if not cfg_path.exists(): - return "пропущен — нет openclaw.json" - - try: - data = json.loads(cfg_path.read_text()) - except json.JSONDecodeError: - return "пропущен — невалидный JSON" - - mcp = data.setdefault("mcpServers", {}) - if "dadata" in mcp: - return "уже есть" - - mcp["dadata"] = MCP_BLOCK["dadata"] - cfg_path.write_text(json.dumps(data, indent=2, ensure_ascii=False)) - return "DaData MCP добавлен" diff --git a/migrations/016_remove_mcp_servers.py b/migrations/016_remove_mcp_servers.py deleted file mode 100644 index 5a330e6..0000000 --- a/migrations/016_remove_mcp_servers.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Удалить mcpServers из openclaw.json всех инстансов. -mcpServers не поддерживается в текущей версии OpenClaw на этих инстансах.""" -import pathlib, json - -DESCRIPTION = "remove mcpServers from openclaw.json — not supported in current version" - - -def apply(workspace: pathlib.Path): - cfg = workspace.parent / "openclaw.json" - if not cfg.exists(): - return "нет openclaw.json" - data = json.loads(cfg.read_text()) - if "mcpServers" not in data: - return "уже чисто" - del data["mcpServers"] - cfg.write_text(json.dumps(data, indent=2)) - return "mcpServers удалён из openclaw.json" diff --git a/migrations/021_cleanup_session_key.py b/migrations/021_cleanup_session_key.py deleted file mode 100644 index ad26fc1..0000000 --- a/migrations/021_cleanup_session_key.py +++ /dev/null @@ -1,27 +0,0 @@ -"""Удалить CLAUDE_AI_SESSION_KEY из openclaw.json всех инстансов — это поле хрупкое и конфликтует с auth-profiles.json.""" -import pathlib -import json - -DESCRIPTION = "remove CLAUDE_AI_SESSION_KEY from openclaw.json (conflicts with auth-profiles.json)" - - -def apply(workspace: pathlib.Path): - # openclaw.json живёт рядом с workspace: instances//openclaw_data/openclaw.json - openclaw_json = workspace.parent / "openclaw.json" - if not openclaw_json.exists(): - return "openclaw.json не найден — пропускаем" - - data = json.loads(openclaw_json.read_text()) - - env_vars = data.get("env", {}).get("vars", {}) - if "CLAUDE_AI_SESSION_KEY" not in env_vars: - return "CLAUDE_AI_SESSION_KEY уже отсутствует — ок" - - del env_vars["CLAUDE_AI_SESSION_KEY"] - - # Убедимся что структура сохранена - if "env" in data and "vars" in data["env"]: - data["env"]["vars"] = env_vars - - openclaw_json.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n") - return "CLAUDE_AI_SESSION_KEY удалён из openclaw.json ✓" diff --git a/migrations/022_update_admin_agents.py b/migrations/022_update_admin_agents.py deleted file mode 100644 index 59517a2..0000000 --- a/migrations/022_update_admin_agents.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Add DANGER ZONE, filesystem isolation map, and quota-proxy sections to admin AGENTS.md.""" -import pathlib -import re - -DESCRIPTION = "add key management, filesystem map, and quota-proxy docs to admin AGENTS.md" - -# Sections to inject after "## 🔑 Ключевой принцип (токены)" block -DANGER_ZONE = """\ - -## 🚨 DANGER ZONE — Ключи и Tokens - -### Карта ключей CortexForge - -| Переменная | Где живёт | Значение | Кто использует | -|---|---|---|---| -| `ANTHROPIC_API_KEY` (в `.env` корня) | Только в quota-proxy | `sk-ant-xxx` (реальный ключ) | quota-proxy для форвардинга | -| `ANTHROPIC_API_KEY` (в docker env инстанса) | docker-compose.yml | = `QUOTA_KEY_USER_1` (НЕ реальный ключ!) | OpenClaw через openclaw.json | -| `QUOTA_KEY_USER_1` (в `.env` корня) | docker-compose.yml env mapping | `quota-user-1-xxx` (хэшируется в proxy) | Подставляется как ANTHROPIC_API_KEY | -| `QUOTA_ADMIN_TOKEN` | Только Admin и quota-proxy | 32+ символов | Admin API quota-proxy | -| `BROKER_KEY_*` | Broker + каждый инстанс | Рандомная строка (хэшируется в broker) | Аутентификация в мессенджере | - -### 🔴 Красная зона — НИКОГДА НЕ ДЕЛАЙ - -- **НИКОГДА** не копируй `ANTHROPIC_API_KEY` из корневого `.env` в инстанс -- **НИКОГДА** не редактируй `.env` файлы вручную через docker — используй скрипты на хосте -- **НИКОГДА** не меняй `QUOTA_KEY_*` в рантайме — нужен `docker compose restart` -- **НИКОГДА** не давай инстансу прямой ключ к Anthropic API -- **НИКОГДА** не пиши реальный `sk-ant-*` ключ в файлы инстансов, логи или сообщения - -### 🟢 Зелёная зона — КАК ПРАВИЛЬНО - -- Добавить инстанс: `cd /infra && make add-user ...` (запускать на хосте или через docker socket exec) -- Поменять лимит: `cd /infra && bash scripts/quota.sh set-limit ` (без рестарта) -- Сбросить квоту: `cd /infra && bash scripts/quota.sh reset ` -- Перезапустить инстанс: `docker restart corp-` (через docker.sock) - ---- - -## 🗄️ Файловая система — полная карта изоляции - -### Что видит каждый контейнер - -| Путь в контейнере | Хост-путь | Права | Кто видит | -|---|---|---|---| -| `/home/node/.openclaw/` | `instances//openclaw_data/` | rw | Свой инстанс | -| `/home/node/.openclaw/workspace/` | `../-workspace/` | rw | Свой инстанс | -| `/shared/skills/` | `shared/skills/` | rw (ro для service-agent) | Все инстансы | -| `/shared/docs/` | `shared/docs/` | rw | Все инстансы | -| `/shared/compliance-data/` | `shared/compliance-data/` | rw (admin), ro (user instances) | Все инстансы | -| `/infra/` | `./` (корень проекта) | rw | **ТОЛЬКО Admin** | -| `/var/run/docker.sock` | `/var/run/docker.sock` | ro | **ТОЛЬКО Admin** | - -### ⚠️ Важно: все обслуживающие операции НАДО выполнять через: -- Скрипты в `/infra/scripts/` (Makefile targets) -- Docker socket (`docker restart`, `docker logs`) -- **НЕ** через прямое редактирование файлов инстансов - ---- - -## ⚙️ Как работает quota-proxy — для понимания - -### Схема потока запроса - -``` -1. OpenClaw шлёт запрос на baseUrl (http://quota-proxy:9090) - с x-api-key = ANTHROPIC_API_KEY (из env = QUOTA_KEY_*) -2. quota-proxy хэширует ключ через SHA256 -3. Сравнивает с хэшами QUOTA_KEY_* из корневого .env (constant-time через hmac.compare_digest) -4. Если совпал — определяет имя инстанса, проверяет лимит -5. Форвардит в upstream (по умолчанию CLIProxyAPI — OAuth-прокси для Claude Max; - admin ходит напрямую в api.anthropic.com если UPSTREAM_URL переопределён) -6. Логирует расход токенов в SQLite -``` - -### Почему инстансы получают ANTHROPIC_API_KEY = quota key - -Это ключевой нюанс, который легко перепутать: - -1. `openclaw.json.template` содержит `"apiKey": "${ANTHROPIC_API_KEY}"` -2. Docker Compose маппит: `ANTHROPIC_API_KEY=${QUOTA_KEY_USER_1}` -3. OpenClaw видит env-переменную `ANTHROPIC_API_KEY`, подставляет в apiKey -4. Результат: запрос идёт на quota-proxy с quota key, proxy подменяет на реальный - -**Это значит:** когда ты видишь `ANTHROPIC_API_KEY` в env инстанса — это **НЕ** реальный ключ Anthropic! Это quota-ключ, который quota-proxy распознаёт и подменяет. -""" - -MARKER = "## 🚨 DANGER ZONE" - - -def apply(workspace: pathlib.Path): - # Only apply to admin instance - # New convention: ../admin-workspace/ - # Legacy: instances/admin/openclaw_data/workspace/ or instances/admin/workspace/ - is_admin = ( - workspace.name == "admin-workspace" - or workspace.parent.parent.name == "admin" - or workspace.parent.name == "admin" - ) - if not is_admin: - return - - agents = workspace / "AGENTS.md" - if not agents.exists(): - return - - content = agents.read_text() - - # Already applied - if MARKER in content: - return - - # Insert after the "Ключевой принцип" section's closing --- - pattern = r"(## 🔑 Ключевой принцип \(токены\).*?---)" - match = re.search(pattern, content, re.DOTALL) - if match: - insert_point = match.end() - new_content = content[:insert_point] + DANGER_ZONE + content[insert_point:] - agents.write_text(new_content) diff --git a/migrations/README.md b/migrations/README.md index 85d6084..38976f1 100644 --- a/migrations/README.md +++ b/migrations/README.md @@ -9,7 +9,7 @@ DESCRIPTION = "что делает миграция" def apply(workspace: pathlib.Path): - """workspace — путь к instances//openclaw_data/workspace/""" + """workspace — путь к instances//workspace/""" ... ``` diff --git a/quota-proxy/proxy.py b/quota-proxy/proxy.py index fabc400..5f5f22b 100644 --- a/quota-proxy/proxy.py +++ b/quota-proxy/proxy.py @@ -292,7 +292,7 @@ def _is_admin(self) -> bool: def _get_instance(self) -> str | None: key = self.headers.get("x-api-key", "") if not key: - # OpenClaw с ANTHROPIC_BASE_URL может слать Authorization: Bearer + # Агент с ANTHROPIC_BASE_URL может слать Authorization: Bearer auth = self.headers.get("Authorization", "") if auth.startswith("Bearer "): key = auth[7:] diff --git a/scripts/add-user.sh b/scripts/add-user.sh index 327c3fe..2f861aa 100644 --- a/scripts/add-user.sh +++ b/scripts/add-user.sh @@ -51,13 +51,13 @@ fi echo "🚀 Создаём инстанс: $FULL_NAME ($NAME)" -mkdir -p "$TARGET/openclaw_data" +mkdir -p "$TARGET/hermes_data" # Создаём workspace внутри директории инстанса INSTANCE_WORKSPACE="$TARGET/workspace" if [ ! -d "$INSTANCE_WORKSPACE" ]; then cp -r "$TEMPLATE/workspace/" "$INSTANCE_WORKSPACE" - rm -rf "$INSTANCE_WORKSPACE/memory" "$INSTANCE_WORKSPACE/.openclaw" + rm -rf "$INSTANCE_WORKSPACE/memory" echo " ✅ Workspace создан в instances/$NAME/workspace/" fi mkdir -p "$INSTANCE_WORKSPACE/skills" "$INSTANCE_WORKSPACE/memory" @@ -80,14 +80,9 @@ QUOTA_KEY=$(python3 -c "import secrets; print('quota-${NAME}-' + secrets.token_u GATEWAY_TOKEN=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") NAME_UPPER=$(echo "$NAME" | tr '[:lower:]' '[:upper:]') -# .env инстанса +# .env инстанса (compose env_file) — ключи для скиллов и брокера cat > "$TARGET/.env" << EOF # Секреты инстанса $NAME — НЕ коммитить в git! -TELEGRAM_BOT_TOKEN=$BOT_TOKEN -TELEGRAM_ALLOW_FROM=${TG_ID} -GATEWAY_TOKEN=$GATEWAY_TOKEN - -# Broker & Quota BROKER_URL=http://message-broker:8080 BROKER_KEY=$BROKER_KEY @@ -101,21 +96,23 @@ EOF chmod 600 "$TARGET/.env" -# OpenClaw конфиг — кладём внутрь openclaw_data/ (единственный mount) -cp "$TEMPLATE/openclaw.json.template" "$TARGET/openclaw_data/openclaw.json" -python3 -c " -import sys, pathlib -bot_token, tg_id, path = sys.argv[1], sys.argv[2], sys.argv[3] -p = pathlib.Path(path) -text = p.read_text() -text = text.replace('{{BOT_TOKEN}}', bot_token).replace('{{TG_ID}}', tg_id) -p.write_text(text) -" "$BOT_TOKEN" "$TG_ID" "$TARGET/openclaw_data/openclaw.json" - -# Выставляем владельца — OpenClaw работает от uid 1000 (node). +# Hermes конфиг и секреты — кладём в hermes_data/ (маунтится в /opt/data) +cp "$TEMPLATE/hermes_config.yaml.template" "$TARGET/hermes_data/config.yaml" + +cat > "$TARGET/hermes_data/.env" << EOF +# Секреты Hermes — НЕ коммитить в git! +TELEGRAM_BOT_TOKEN=$BOT_TOKEN +TELEGRAM_ALLOWED_USERS=${TG_ID} +HERMES_GATEWAY_TOKEN=$GATEWAY_TOKEN +ANTHROPIC_API_KEY=$QUOTA_KEY +# OPENAI_API_KEY= # для whisper — пробрасывается из глобального .env через compose +EOF +chmod 600 "$TARGET/hermes_data/.env" + +# Выставляем владельца — HERMES_UID/HERMES_GID = 1000 (совпадает с chown). # Используем docker alpine для chown, т.к. deploy user может не иметь прав на chown. docker run --rm \ - -v "$(pwd)/$TARGET/openclaw_data:/data1" \ + -v "$(pwd)/$TARGET/hermes_data:/data1" \ -v "$(pwd)/$TARGET/workspace:/data2" \ alpine chown -R 1000:1000 /data1 /data2 @@ -144,8 +141,8 @@ service = f""" env_file: - instances/{name}/.env volumes: - - ./instances/{name}/openclaw_data:/home/node/.openclaw - - ./instances/{name}/workspace:/home/node/.openclaw/workspace + - ./instances/{name}/hermes_data:/opt/data + - ./instances/{name}/workspace:/opt/data/workspace - ./shared/skills:/shared/skills - ./shared/docs:/shared/docs - ./shared/compliance-data:/shared/compliance-data:ro @@ -155,9 +152,11 @@ service = f""" - SERVICE_API_KEY=${{SERVICE_API_KEY}} - BROKER_URL=http://message-broker:8080 - BROKER_KEY=${{{f"BROKER_KEY_{NAME_UPPER}"}}} - - NODE_OPTIONS=--max-old-space-size=768 + - HERMES_UID=1000 + - HERMES_GID=1000 + - HERMES_DASHBOARD=1 ports: - - "127.0.0.1:{ui_port}:18789" # OpenClaw Control UI + - "127.0.0.1:{ui_port}:9119" # Hermes Dashboard networks: - corp-internal # quota-proxy + broker (internal) - corp-outbound # Telegram API + внешние вызовы @@ -193,7 +192,7 @@ PYEOF echo "" echo "✅ Инстанс '$NAME' создан" echo " Квота: 1,000,000 токенов/мес (изменить: make set-limit NAME=$NAME LIMIT=500000)" -echo " Control UI: http://localhost:$UI_PORT" +echo " Dashboard: http://localhost:$UI_PORT" echo "" echo "📋 Далее:" echo " 1. nano instances/$NAME/.env — персональные токены" diff --git a/scripts/autoupdate.sh b/scripts/autoupdate.sh index 9d8bb70..85b7b07 100755 --- a/scripts/autoupdate.sh +++ b/scripts/autoupdate.sh @@ -39,7 +39,7 @@ if [ "$LOCAL" != "$REMOTE" ]; then CHANGED=true fi -# ── 2. Внешние Docker образы (openclaw:latest и др.) ───────────────────────── +# ── 2. Внешние Docker образы (hermes-agent и др.) ──────────────────────────── PULLED=$(docker compose pull --ignore-buildable 2>&1 || true) if echo "$PULLED" | grep -q "Pulled"; then log "🐳 Новые внешние образы:" diff --git a/scripts/backup.sh b/scripts/backup.sh index 9594b6b..b0533d0 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -29,7 +29,7 @@ fi # ─── 2. Архив instances/ ───────────────────────────────────────────────────── tar -czf "$ARCHIVE" \ - --exclude="instances/*/openclaw_data/agents/main/sessions/*.jsonl" \ + --exclude="instances/*/hermes_data/sessions/*" \ instances/ 2>&1 SIZE=$(du -sh "$ARCHIVE" | cut -f1) diff --git a/scripts/fix-permissions.sh b/scripts/fix-permissions.sh index 056ec1d..2685e56 100644 --- a/scripts/fix-permissions.sh +++ b/scripts/fix-permissions.sh @@ -8,7 +8,7 @@ echo "🔒 Исправляем права файлов..." [ -f .env ] && chmod 600 .env && echo " ✅ .env → 600" # Секреты инстансов -for f in instances/*/.env instances/*/openclaw.json; do +for f in instances/*/.env instances/*/hermes_data/config.yaml instances/*/hermes_data/.env; do [ -f "$f" ] && chmod 600 "$f" && echo " ✅ $f → 600" done diff --git a/scripts/migrate-instances.py b/scripts/migrate-instances.py index 5dd8e95..6bdc341 100644 --- a/scripts/migrate-instances.py +++ b/scripts/migrate-instances.py @@ -57,8 +57,6 @@ def _discover_instances(): """Find instances and their workspace paths. Primary: instances//workspace/ (in-repo). - Fallback: ../-workspace/ (legacy external convention). - Fallback: instances//openclaw_data/workspace/ (legacy). """ parent = _workspace_parent() result = [] @@ -70,16 +68,6 @@ def _discover_instances(): if ws.exists(): result.append((d, ws)) continue - # Legacy: external workspace outside repo - ws_name = "admin-workspace" if d.name == "admin" else f"{d.name}-workspace" - external_ws = parent / ws_name - if external_ws.exists(): - result.append((d, external_ws)) - continue - # Legacy: workspace inside openclaw_data - legacy_ws = d / "openclaw_data" / "workspace" - if legacy_ws.exists(): - result.append((d, legacy_ws)) return result def run(): @@ -123,7 +111,7 @@ def run(): updated.append(instance_dir.name) # Сбрасываем кеш сессий — иначе агент продолжит работать # со старым SOUL.md/AGENTS.md из закешированной истории - sessions_dir = instance_dir / "openclaw_data" / "agents" / "main" / "sessions" + sessions_dir = instance_dir / "hermes_data" / "sessions" if sessions_dir.exists() and not DRY_RUN: for f in sessions_dir.glob("*.jsonl"): f.unlink() diff --git a/scripts/refresh-oauth.py b/scripts/refresh-oauth.py deleted file mode 100755 index 1ccc78d..0000000 --- a/scripts/refresh-oauth.py +++ /dev/null @@ -1,75 +0,0 @@ -#!/usr/bin/env python3 -""" -Host-side OAuth token refresher for Capuchin bots. -Runs refresh-token.py inside corp-admin, then propagates to all instances. -""" -import json, time, os, subprocess, sys - -BASE = '/opt/capuchin/repo/engine/instances' -OAUTH_PATH = f'{BASE}/admin/openclaw_data/oauth/anthropic-oauth.json' -INSTANCES = ['admin', 'user-1', 'user-3', 'user-4'] - -def log(msg): - print(f'[{time.strftime("%Y-%m-%d %H:%M:%S")}] {msg}', flush=True) - -# Проверяем нужно ли обновлять (меньше 2 часов до истечения) -try: - with open(OAUTH_PATH) as f: - oauth = json.load(f) - exp = int(oauth['expiresAt']) / 1000 - remaining = exp - time.time() - log(f'Token expires in {remaining/3600:.1f}h') - if remaining > 2 * 3600 and '--force' not in sys.argv: - log('No refresh needed') - sys.exit(0) -except Exception as e: - log(f'Could not read oauth: {e}') - sys.exit(1) - -# Обновляем через admin контейнер -log('Refreshing via corp-admin...') -result = subprocess.run( - ['docker', 'exec', 'corp-admin', 'python3', - '/home/node/.openclaw/oauth/refresh-token.py', '--force'], - capture_output=True, text=True -) -if result.returncode != 0: - log(f'REFRESH FAILED: {result.stderr}') - sys.exit(1) -log(f'Refreshed OK') - -# Читаем новый токен -with open(OAUTH_PATH) as f: - oauth = json.load(f) -TOKEN = oauth['accessToken'] -exp = int(oauth['expiresAt']) / 1000 -log(f'New token: {TOKEN[:20]}... expires in {(exp-time.time())/3600:.1f}h') - -# Обновляем auth-profiles.json всех инстансов -profile = { - 'version': 1, - 'profiles': { - 'anthropic:default': { - 'type': 'token', - 'provider': 'anthropic', - 'token': TOKEN - } - }, - 'lastGood': {'anthropic': 'anthropic:default'}, - 'usageStats': { - 'anthropic:default': { - 'lastUsed': int(time.time() * 1000), - 'errorCount': 0 - } - } -} - -for inst in INSTANCES: - path = f'{BASE}/{inst}/openclaw_data/agents/main/agent/auth-profiles.json' - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, 'w') as f: - json.dump(profile, f, indent=2) - os.chmod(path, 0o600) - log(f'{inst}: auth-profiles updated') - -log('Done') diff --git a/scripts/update-token.py b/scripts/update-token.py deleted file mode 100755 index ec74c6b..0000000 --- a/scripts/update-token.py +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env python3 -"""Ротация OAuth токена во всех инстансах Capuchin. - -Обновляет auth-profiles.json напрямую (правильное хранилище), -не трогает openclaw.json. - -Использование: - python3 scripts/update-token.py --token sk-ant-oat01-XXX - python3 scripts/update-token.py --token sk-ant-oat01-XXX --restart - python3 scripts/update-token.py --token sk-ant-oat01-XXX --instance admin -""" -import argparse -import json -import pathlib -import subprocess -import sys - -INSTANCES_DIR = pathlib.Path(__file__).parent.parent / "instances" -AUTH_PROFILE_REL = pathlib.Path("openclaw_data/agents/main/agent/auth-profiles.json") - -AUTH_PROFILE_TEMPLATE = { - "version": 1, - "profiles": { - "anthropic:default": { - "type": "token", - "provider": "anthropic", - "token": "", - } - }, - "lastGood": {"anthropic": "anthropic:default"}, -} - - -def update_instance(name: str, token: str) -> str: - inst_dir = INSTANCES_DIR / name - if not inst_dir.exists(): - return f"{name}: директория не найдена — пропущено" - - auth_path = inst_dir / AUTH_PROFILE_REL - auth_path.parent.mkdir(parents=True, exist_ok=True) - - # Читаем существующий профиль или создаём с нуля - if auth_path.exists(): - try: - data = json.loads(auth_path.read_text()) - except json.JSONDecodeError: - data = json.loads(json.dumps(AUTH_PROFILE_TEMPLATE)) - else: - data = json.loads(json.dumps(AUTH_PROFILE_TEMPLATE)) - - # Обновляем токен - data.setdefault("profiles", {}).setdefault("anthropic:default", {})["token"] = token - data.setdefault("lastGood", {})["anthropic"] = "anthropic:default" - - # Сбрасываем счётчик ошибок чтобы OpenClaw не считал профиль битым - if "usageStats" in data and "anthropic:default" in data["usageStats"]: - data["usageStats"]["anthropic:default"]["errorCount"] = 0 - - auth_path.write_text(json.dumps(data, indent=2) + "\n") - return f"{name}: токен обновлён → {auth_path}" - - -def restart_instance(name: str, compose_dir: pathlib.Path) -> str: - service = f"assistant-{name}" - result = subprocess.run( - ["docker", "compose", "restart", service], - cwd=compose_dir, - capture_output=True, - text=True, - ) - if result.returncode == 0: - return f"{name}: контейнер перезапущен" - else: - return f"{name}: ошибка рестарта — {result.stderr.strip()}" - - -def main(): - parser = argparse.ArgumentParser(description="Обновить OAuth токен во всех инстансах Capuchin") - parser.add_argument("--token", required=True, help="Новый OAuth токен (sk-ant-oat01-...)") - parser.add_argument("--instance", help="Конкретный инстанс (по умолчанию — все)") - parser.add_argument("--restart", action="store_true", help="Перезапустить контейнеры после обновления") - args = parser.parse_args() - - if not args.token.startswith("sk-ant-oat01-"): - print(f"⚠️ Токен не похож на OAuth: {args.token[:20]}...", file=sys.stderr) - print("Ожидается формат sk-ant-oat01-...", file=sys.stderr) - sys.exit(1) - - # Определяем инстансы - if args.instance: - instances = [args.instance] - else: - instances = sorted( - d.name for d in INSTANCES_DIR.iterdir() - if d.is_dir() and not d.name.startswith("_") and not d.name.startswith(".") - ) - - compose_dir = INSTANCES_DIR.parent # engine/ - - print(f"Токен: {args.token[:25]}...{args.token[-6:]}") - print(f"Инстансы: {', '.join(instances)}") - print() - - for name in instances: - msg = update_instance(name, args.token) - print(f" ✓ {msg}") - - if args.restart: - print() - print("Перезапуск контейнеров...") - for name in instances: - msg = restart_instance(name, compose_dir) - print(f" {msg}") - - print() - print("Готово. Если не передавал --restart — запусти:") - print(f" docker compose -f {compose_dir}/docker-compose.yml restart {' '.join('assistant-' + n for n in instances)}") - - -if __name__ == "__main__": - main() diff --git a/service-agent/skills/admin-dashboard/KNOWLEDGE.md b/service-agent/skills/admin-dashboard/KNOWLEDGE.md index 1f3d50b..1f74967 100644 --- a/service-agent/skills/admin-dashboard/KNOWLEDGE.md +++ b/service-agent/skills/admin-dashboard/KNOWLEDGE.md @@ -9,7 +9,7 @@ | Переменная | Где живёт | Значение | Кто использует | |---|---|---|---| | ANTHROPIC_API_KEY (корневой .env) | Только quota-proxy | sk-ant-xxx (реальный ключ) | quota-proxy (admin) / CLIProxyAPI OAuth (остальные через UPSTREAM_URL) | -| ANTHROPIC_API_KEY (в docker env инстанса) | docker-compose.yml | = QUOTA_KEY_USER_1 (НЕ реальный ключ!) | OpenClaw через openclaw.json | +| ANTHROPIC_API_KEY (в docker env инстанса) | docker-compose.yml | = QUOTA_KEY_USER_1 (НЕ реальный ключ!) | Hermes через config.yaml | | QUOTA_KEY_\ | Корневой .env → docker-compose.yml mapping | quota-\-xxx | Подставляется как ANTHROPIC_API_KEY инстанса | | QUOTA_ADMIN_TOKEN | Корневой .env | 32+ символов | Admin API quota-proxy, admin-dashboard | | BROKER_KEY_\ | Корневой .env | Рандомная строка | Аутентификация в broker | @@ -18,18 +18,18 @@ ### Как работает quota-proxy (поток запроса) -1. OpenClaw шлёт запрос на baseUrl (http://quota-proxy:9090) с x-api-key = ANTHROPIC_API_KEY (из env = QUOTA_KEY_\*) +1. Hermes шлёт запрос на baseUrl (http://quota-proxy:9090) с x-api-key = ANTHROPIC_API_KEY (из env = QUOTA_KEY_\*) 2. quota-proxy хэширует ключ через SHA256 3. Сравнивает с хэшами QUOTA_KEY_\* из корневого .env (constant-time через hmac.compare_digest) 4. Если совпал — определяет имя инстанса, проверяет лимит 5. Форвардит в upstream (по умолчанию CLIProxyAPI — OAuth-прокси для Claude Max; admin ходит напрямую в api.anthropic.com) 6. Логирует расход токенов в SQLite -### Почему в openclaw.json стоит ANTHROPIC_API_KEY = quota key +### Почему в config.yaml стоит ANTHROPIC_API_KEY = quota key -- openclaw.json.template содержит `"apiKey": "${ANTHROPIC_API_KEY}"` +- hermes_config.yaml.template содержит `api_key: ${ANTHROPIC_API_KEY}` - Docker Compose маппит: `ANTHROPIC_API_KEY=${QUOTA_KEY_USER_1}` -- OpenClaw видит env-переменную ANTHROPIC_API_KEY, подставляет в apiKey +- Hermes видит env-переменную ANTHROPIC_API_KEY, подставляет в api_key - Результат: запрос идёт на quota-proxy с quota key, proxy подменяет на реальный ## 2. Сети Docker diff --git a/service-agent/skills/compliance/enrich.py b/service-agent/skills/compliance/enrich.py index ad75861..5d5398f 100644 --- a/service-agent/skills/compliance/enrich.py +++ b/service-agent/skills/compliance/enrich.py @@ -12,7 +12,7 @@ ${ENRICH_PY_PATH:-./service-agent/skills/compliance/enrich_placeholder.py}:/app/skills/compliance/enrich.py:ro Set ENRICH_PY_PATH in .env to the actual path: - ENRICH_PY_PATH=/home/user/.openclaw/workspace/skills/compliance-risk/scripts/enrich.py + ENRICH_PY_PATH=/opt/data/workspace/skills/compliance-risk/scripts/enrich.py Option B — copy before build: cp ~/workspace/skills/compliance-risk/scripts/enrich.py \\ diff --git a/service-agent/skills/qmd/run.py b/service-agent/skills/qmd/run.py index eb74991..249d2b3 100644 --- a/service-agent/skills/qmd/run.py +++ b/service-agent/skills/qmd/run.py @@ -6,7 +6,7 @@ Рекурсивный обход директорий с поиском по содержимому .md файлов. Scopes: - workspace — /home/node/.openclaw/workspace/ + workspace — /opt/data/workspace/ shared — /shared/docs/ + /shared/skills/ skills — /shared/skills/ only all — всё вышеперечисленное @@ -21,7 +21,7 @@ import time # Directories per scope (overridable via env for testing) -_WORKSPACE_DIR = os.environ.get("QMD_WORKSPACE_DIR", "/home/node/.openclaw/workspace/") +_WORKSPACE_DIR = os.environ.get("QMD_WORKSPACE_DIR", "/opt/data/workspace/") _SHARED_DOCS_DIR = os.environ.get("QMD_SHARED_DOCS_DIR", "/shared/docs/") _SHARED_SKILLS_DIR = os.environ.get("QMD_SHARED_SKILLS_DIR", "/shared/skills/") diff --git a/shared/skills/qmd/SKILL.md b/shared/skills/qmd/SKILL.md index 1004688..8b1d10e 100644 --- a/shared/skills/qmd/SKILL.md +++ b/shared/skills/qmd/SKILL.md @@ -63,7 +63,7 @@ Endpoint: `POST http://corp-service:8090/v1/run` | Scope | Директории | |-------|-----------| -| `workspace` | /home/node/.openclaw/workspace/ (твой workspace) | +| `workspace` | /opt/data/workspace/ (твой workspace) | | `shared` | /shared/docs/ + /shared/skills/ | | `skills` | /shared/skills/ only | | `all` | Все вышеперечисленные | diff --git a/shared/skills/yandex-oauth/SKILL.md b/shared/skills/yandex-oauth/SKILL.md index 31e0bfa..9e57694 100644 --- a/shared/skills/yandex-oauth/SKILL.md +++ b/shared/skills/yandex-oauth/SKILL.md @@ -34,7 +34,7 @@ source ~/.secrets/gitlab.env ## Вспомогательные скрипты -Уже готовы в `/home/user/.openclaw/workspace/`: +Уже готовы в `/opt/data/workspace/`: - `calendar.py` — чтение событий CalDAV (`python3 calendar.py --days 7`) - `tracker.py` — задачи на меня (`python3 tracker.py`) diff --git a/shared/skills/yandex-oauth/references/calendar.md b/shared/skills/yandex-oauth/references/calendar.md index 3a81e13..5bba70d 100644 --- a/shared/skills/yandex-oauth/references/calendar.md +++ b/shared/skills/yandex-oauth/references/calendar.md @@ -13,7 +13,7 @@ source ~/.secrets/gitlab.env ```bash # Посмотреть события на ближайшие N дней -python3 /home/user/.openclaw/workspace/calendar.py --days 7 +python3 /opt/data/workspace/calendar.py --days 7 ``` ## Чтение событий (curl) diff --git a/shared/skills/yandex-oauth/references/tracker.md b/shared/skills/yandex-oauth/references/tracker.md index 7474bfc..146bf52 100644 --- a/shared/skills/yandex-oauth/references/tracker.md +++ b/shared/skills/yandex-oauth/references/tracker.md @@ -19,7 +19,7 @@ Base URL: `https://api.tracker.yandex.net/v2` ## Готовый скрипт ```bash -python3 /home/user/.openclaw/workspace/tracker.py +python3 /opt/data/workspace/tracker.py ``` ## Создать задачу From 3c2ef15d5ac79d81c631b8c6b7162ce77facb08b Mon Sep 17 00:00:00 2001 From: nikita42 <13642481+rekurt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:32:19 +0300 Subject: [PATCH 2/5] fix: address team review findings (Megatron + Ratchet) Blockers/high: - add-user.sh: TG_ID required (empty allowlist = silent dead bot), root .env required for quota registration, trailing-newline guard before append, first-free-port scan, SOUL.md copied to hermes_data (Hermes reads persona from ~/.hermes), docker compose config validation - scripts/provision-hermes-data.sh + make bootstrap: hermes_data for admin and existing instances (dirs, .env mapped from legacy var names, SOUL.md, chown 1000:1000) - legacy OAuth scripts removed: refresh-anthropic-token.sh, refresh_token.py (+CLAUDE.md row) - sync-instances.sh: workspace path fixed (legacy -workspace layout was silently dead); WORKSPACE_PARENT removed everywhere - compose: group_add ${DOCKER_GID:-999} (was hardcoded 988), honest docker.sock comment - Dockerfiles: pinned v0.22.5 (no floating stable + 5-min autoupdate); admin installs only docker-compose-plugin (host CLI is mounted anyway) - template config: provider anthropic + base_url override (invented 'quota-proxy' provider name could fail validation) Majors/minors: - backup.sh: excludes .env secrets, chmod 600 archive - fix-permissions.sh: chown 1000:1000 hermes_data - migrate-instances.py: load_migration wrapped in try, dead code removed - .env.example / CONTRIBUTING / README x2 / SETUP: TELEGRAM_ALLOWED_USERS, HERMES_GATEWAY_TOKEN, OPENAI_API_KEY - .gitignore: OpenClaw-era entries removed - template workspace ships .migrations_applied (fresh installs skip the 20 historical migrations) - docs/migration-hermes.md: VPS deploy runbook (token preservation!), honest rollback section, Q&A closed with doc evidence --- .gitignore | 4 - CLAUDE.md | 1 - CONTRIBUTING.md | 2 +- Makefile | 7 +- README.md | 4 +- README.ru.md | 4 +- docker-compose.yml | 5 +- docs/SETUP.md | 2 +- docs/migration-hermes.md | 45 +++++-- instances/.env.example | 9 +- instances/Dockerfile.user | 2 +- .../_template/hermes_config.yaml.template | 4 +- .../_template/workspace/.migrations_applied | 20 ++++ instances/admin/Dockerfile | 4 +- scripts/add-user.sh | 39 ++++-- scripts/backup.sh | 3 + scripts/fix-permissions.sh | 8 ++ scripts/migrate-instances.py | 18 +-- scripts/provision-hermes-data.sh | 66 +++++++++++ scripts/refresh-anthropic-token.sh | 47 -------- scripts/refresh_token.py | 112 ------------------ scripts/sync-instances.sh | 3 +- 22 files changed, 193 insertions(+), 216 deletions(-) create mode 100644 instances/_template/workspace/.migrations_applied create mode 100755 scripts/provision-hermes-data.sh delete mode 100755 scripts/refresh-anthropic-token.sh delete mode 100755 scripts/refresh_token.py diff --git a/.gitignore b/.gitignore index 013faf4..69b953c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,12 +4,8 @@ docker-compose.override.yml # Секреты — никогда в git .env -.anthropic_tokens.json -.cli-proxy-api/ -Codex-*.json instances/*/.env instances/*/hermes_data/ -**/*.env **/*.key **/*.token **/*.db diff --git a/CLAUDE.md b/CLAUDE.md index 004004a..26ae934 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -324,7 +324,6 @@ instances//hermes_data/ # маунт → /opt/data (~/.hermes) | `scripts/autoupdate.sh` | Cron-скрипт (каждые 5 мин): git pull + rebuild changed | | `scripts/sync-instances.sh` | Ежечасный maintenance от admin: миграции, healthcheck | | `scripts/fix-permissions.sh` | Восстановить права 600 на .env после git-операций | -| `scripts/refresh-anthropic-token.sh` | Обновить OAuth-токен из `ANTHROPIC_REFRESH_TOKEN` | | `scripts/migrate-instances.py` | Применить миграции ко всем инстансам | ## Gotchas diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f03f03f..280c49f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ cp .env.example .env # Admin instance (required) mkdir -p instances/admin cp instances/.env.example instances/admin/.env -# Edit instances/admin/.env: set TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOW_FROM +# Edit instances/admin/.env: set TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS # Launch make deploy diff --git a/Makefile b/Makefile index d837435..b90e4a4 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: add-user remove-user deploy restart logs backup status quota-report quota-reset set-limit security-check monitor monitor-alerts add-service service-health service-skills install-hooks admin-overview +.PHONY: add-user remove-user bootstrap deploy restart logs backup status quota-report quota-reset set-limit security-check monitor monitor-alerts add-service service-health service-skills install-hooks admin-overview # ── Управление инстансами ────────────────────────────────────────────────── @@ -13,6 +13,11 @@ remove-user: @[ -n "$(NAME)" ] || (echo "❌ NAME= обязателен"; exit 1) @bash scripts/remove-user.sh "$(NAME)" +# make bootstrap — подготовить hermes_data инстансов (каталоги, .env, SOUL.md, права) +bootstrap: + @bash scripts/provision-hermes-data.sh + @echo "✅ hermes_data готов" + # ── Деплой ──────────────────────────────────────────────────────────────── deploy: diff --git a/README.md b/README.md index 54f1f5d..5c1db0c 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ Required fields: ```bash TELEGRAM_BOT_TOKEN=0000000000:CHANGE_ME_BOT_TOKEN -TELEGRAM_ALLOW_FROM=000000000 # your Telegram user ID +TELEGRAM_ALLOWED_USERS=000000000 # your Telegram user ID BROKER_KEY= # must match the global .env value ``` @@ -284,7 +284,7 @@ make status # all containers should be Up (healthy) | Variable | Required | Description | |---|---|---| | `TELEGRAM_BOT_TOKEN` | ✅ | Telegram bot token placeholder | -| `TELEGRAM_ALLOW_FROM` | ✅ | Allowed Telegram ID(s), comma-separated | +| `TELEGRAM_ALLOWED_USERS` | ✅ | Allowed Telegram ID(s), comma-separated | | `BROKER_KEY` | ✅ | Instance broker key (auto-generated by `add-user.sh`) | | `YANDEX_OAUTH_TOKEN` | — | Yandex OAuth for mail / Tracker | | `YANDEX_CALDAV_URL` | — | Yandex Calendar CalDAV URL | diff --git a/README.ru.md b/README.ru.md index 4d57c9e..2ce53be 100644 --- a/README.ru.md +++ b/README.ru.md @@ -217,7 +217,7 @@ nano instances/admin/.env ```bash TELEGRAM_BOT_TOKEN=0000000000:CHANGE_ME_BOT_TOKEN -TELEGRAM_ALLOW_FROM=000000000 # Telegram user ID +TELEGRAM_ALLOWED_USERS=000000000 # Telegram user ID BROKER_KEY=<то же что BROKER_KEY_ADMIN> # должно совпадать с глобальным .env ``` @@ -282,7 +282,7 @@ make status # все контейнеры должны быть Up (healthy) | Переменная | Обязательно | Описание | |---|---|---| | `TELEGRAM_BOT_TOKEN` | ✅ | Placeholder токена Telegram-бота | -| `TELEGRAM_ALLOW_FROM` | ✅ | Разрешённые Telegram ID через запятую | +| `TELEGRAM_ALLOWED_USERS` | ✅ | Разрешённые Telegram ID через запятую | | `BROKER_KEY` | ✅ | Ключ брокера (генерируется `add-user.sh`) | | `YANDEX_OAUTH_TOKEN` | — | Яндекс OAuth для почты / Трекера | | `YANDEX_CALDAV_URL` | — | CalDAV URL Яндекс Календаря | diff --git a/docker-compose.yml b/docker-compose.yml index b644c3e..cdc8dd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -130,7 +130,7 @@ services: # Весь проект (rw) — admin уже имеет ANTHROPIC_API_KEY через environment - ./:/infra/ - ../:/infra-parent/ # доступ к внешним данным - - /var/run/docker.sock:/var/run/docker.sock:rw # статус контейнеров (read-only) + - /var/run/docker.sock:/var/run/docker.sock:rw # docker API (self-healing, quota-скиллы) - /usr/bin/docker:/usr/bin/docker:ro # docker CLI с хоста environment: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # admin ходит напрямую @@ -138,7 +138,6 @@ services: - BROKER_KEY=${BROKER_KEY_ADMIN} - QUOTA_PROXY_URL=http://quota-proxy:9090 - QUOTA_ADMIN_TOKEN=${QUOTA_ADMIN_TOKEN} - - WORKSPACE_PARENT=/infra-parent # для sync-instances.sh и migrate-instances.py - SERVICE_API_KEY=${SERVICE_API_KEY} - HERMES_UID=1000 - HERMES_GID=1000 @@ -150,7 +149,7 @@ services: - corp-admin - corp-outbound group_add: - - "988" # docker group + - "${DOCKER_GID:-999}" # docker group GID — доступ к /var/run/docker.sock security_opt: - no-new-privileges:true deploy: diff --git a/docs/SETUP.md b/docs/SETUP.md index 5f7171e..b2c25cd 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -79,7 +79,7 @@ nano instances/admin/.env | Ключ | Описание | |---|---| | `TELEGRAM_BOT_TOKEN` | Placeholder токена Telegram-бота Admin | -| `TELEGRAM_ALLOW_FROM` | Telegram ID администратора | +| `TELEGRAM_ALLOWED_USERS` | Telegram ID администратора | | `BROKER_KEY` | То же значение что `BROKER_KEY_ADMIN` в `.env` | --- diff --git a/docs/migration-hermes.md b/docs/migration-hermes.md index 4b72dd5..a382cdf 100644 --- a/docs/migration-hermes.md +++ b/docs/migration-hermes.md @@ -26,7 +26,7 @@ CortexForge переезжает с [OpenClaw](https://github.com/openclaw/openc | Что | Значение | |---|---| -| Образ | `nousresearch/hermes-agent` (Docker Hub, amd64/arm64) | +| Образ | `nousresearch/hermes-agent` — запинен `v0.22.5` (не `stable`/`latest`: плавающий тег + auto-update = неконтролируемый дрейф; обновления вручную) | | Запуск | `command: ["gateway", "run"]`, ENTRYPOINT `/init` (s6-overlay) — не переопределять | | Данные | том `…:/opt/data` = `~/.hermes`: `.env`, `config.yaml`, `SOUL.md`, `sessions/`, `memories/`, `skills/`, `home/`, `logs/` | | Права | env `HERMES_UID`/`HERMES_GID` — s6 ремапит внутреннего пользователя; у нас `1000:1000` (как раньше `node`) | @@ -108,11 +108,32 @@ instances// ## 7. Rollback -`git checkout <прошлый тег> && docker compose up -d` — код откатывается, данные не переносились, -поэтому откат означает старт с чистыми старыми воркспейсами. Квоты в quota-proxy (SQLite) -не зависят от агента и остаются. - -## 8. Оценка +Big-bang без импорта данных: откат = **повторный fresh-install на старой версии**. +`git checkout <прошлый тег> && docker compose up -d` поднимает старый код, но старые +`openclaw_data/` удалены — инстансы нужно пересоздать через `make add-user` с теми же +`BOT_TOKEN`/`TG_ID` (см. §8, шаг 1 — токены сохраняются до wipe). Квоты в quota-proxy +(SQLite) не зависят от агента и остаются. + +## 8. Runbook деплоя на VPS + +1. **Сохранить токены ДО wipe** — иначе Telegram-боты не восстановить: со старого хоста + забрать `instances/*/.env` и/или `openclaw_data/*/openclaw.json` (оттуда достаются + `BOT_TOKEN` и `allowFrom`). Хранить вне репо. +2. `git fetch && git checkout migrate/hermes` (после ревью — merge в master и pull). +3. `make bootstrap` — создаёт `instances/*/hermes_data` (+`.env` из старых имён переменных, + `SOUL.md`, права 1000:1000). +4. Пересоздать инстансы: `make add-user NAME=… BOT_TOKEN=… TG_ID=…` (токены из шага 1). +5. `docker compose build && docker compose up -d`. +6. Smoke-тест одного инстанса по чек-листу §6 + доп-проверки: + - dashboard открывается на `127.0.0.1:<порт>`; если connection refused — dashboard + биндится на loopback контейнера (проверить `--host`/config dashboard у Hermes); + - интерполяция `${VAR}` в config.yaml работает (`hermes status` внутри контейнера); + - фактический layout `hermes_data/sessions/` сверить с exclude в `backup.sh`; + - gateway API `8642` — если dashboard-UI ходит к API с хоста, опубликовать порт. +7. Повторить для остальных инстансов, удалить старые `openclaw_data/` и образы + `ghcr.io/openclaw/*`. + +## 9. Оценка | Этап | Оценка | |---|---| @@ -120,15 +141,19 @@ instances// | Деплой и верификация | 0,5 дня | | **Итого** | **~3 дня** (простой инстансов: часы, а не дни) | -## 9. Открытые вопросы — закрыты (проверено по докам и Docker Hub registry) +## 10. Открытые вопросы — закрыты (проверено по докам и Docker Hub registry) - **Q1** OAuth Claude Max: живёт в CLIProxyAPI (volume `cliproxyapi-auths`); инстансы ходят - через quota-proxy с quota-key. `refresh-oauth.py`/`update-token.py` удалены как легаси. -- **Q2** Тег образа: `nousresearch/hermes-agent:stable` существует (не только `-desktop`) — - проверено через registry API. + через quota-proxy с quota-key. `refresh-oauth.py`/`update-token.py`/`refresh-anthropic-token.sh`/ + `refresh_token.py` удалены как легаси. +- **Q2** Тег образа: существует `stable` и версионные теги; **выбран и запинен `v0.22.5`** + (плавающий `stable` + auto-update = неконтролируемый дрейф, см. R-ревью). - **Q3** Голосовые: транскрибируются gateway автоматически; STT — `voice.stt.provider` в `config.yaml` (`openai` / `local` faster-whisper). В шаблон добавлен `openai`. - **Q4** Сессии: `~/.hermes/sessions/` → exclude в `backup.sh` верный. - **Q5** Порты: dashboard `9119`, gateway API `8642`. - **Q6** Telegram: `TELEGRAM_BOT_TOKEN` + `TELEGRAM_ALLOWED_USERS` (числовые ID через запятую) в `~/.hermes/.env`; deny-by-default — семантика соответствует старому `allowFrom`. +- **Доп. подтверждения по докам:** интерполяция `${VAR}` в config.yaml — документирована + (Hermes Configuration, «Environment Variable Substitution»); ремап пользователя через + `HERMES_UID/GID` (s6-overlay stage2) — описан в официальном docker-compose.yml Hermes. diff --git a/instances/.env.example b/instances/.env.example index b5c8064..bc91c05 100644 --- a/instances/.env.example +++ b/instances/.env.example @@ -6,11 +6,14 @@ TELEGRAM_BOT_TOKEN=change-me-bot-token # Telegram ID(s) которым разрешён доступ (через запятую) -TELEGRAM_ALLOW_FROM=change-me-telegram-id +TELEGRAM_ALLOWED_USERS=change-me-telegram-id -# Gateway token — аутентификация Control UI (генерируется через add-user.sh) +# Gateway token — аутентификация Hermes gateway (генерируется через add-user.sh) # Для admin: python3 -c "import secrets; print(secrets.token_urlsafe(32))" -GATEWAY_TOKEN=change-me-gateway-token +HERMES_GATEWAY_TOKEN=change-me-gateway-token + +# OpenAI API key — для транскрипции голосовых (voice.stt.provider: openai) +OPENAI_API_KEY=change-me-openai-key # Broker — внутренняя шина (URL не менять, ключ генерируется через add-user.sh) BROKER_URL=http://message-broker:8080 diff --git a/instances/Dockerfile.user b/instances/Dockerfile.user index 982712e..ff5d37d 100644 --- a/instances/Dockerfile.user +++ b/instances/Dockerfile.user @@ -1,4 +1,4 @@ -FROM nousresearch/hermes-agent:stable +FROM nousresearch/hermes-agent:v0.22.5 # ffmpeg — для транскрипции голосовых (whisper) # tesseract-ocr — распознавание текста на изображениях (OCR, rus+eng) diff --git a/instances/_template/hermes_config.yaml.template b/instances/_template/hermes_config.yaml.template index be6f7f7..6dd4b64 100644 --- a/instances/_template/hermes_config.yaml.template +++ b/instances/_template/hermes_config.yaml.template @@ -5,7 +5,7 @@ # ANTHROPIC_API_KEY (= QUOTA_KEY_ — ключ quota-proxy), OPENAI_API_KEY model: - provider: quota-proxy # кастомный провайдер — локальный quota-proxy + provider: anthropic # anthropic-совместимый эндпоинт (quota-proxy) default: claude-sonnet-4-6 base_url: http://quota-proxy:9090 api_mode: anthropic_messages @@ -20,5 +20,7 @@ voice: terminal: cwd: /opt/data/workspace + # backend по умолчанию — local exec (агент уже в контейнере; + # docker-сэндбокс не нужен — у user-инстансов нет docker socket) timezone: Europe/Moscow diff --git a/instances/_template/workspace/.migrations_applied b/instances/_template/workspace/.migrations_applied new file mode 100644 index 0000000..aa0dff5 --- /dev/null +++ b/instances/_template/workspace/.migrations_applied @@ -0,0 +1,20 @@ +001_add_corp_greeting +002_bolder_soul +003_capuchin_soul +004_corp_docs_skill +005_start_greeting +006_monkey_soul +007_less_brief +008_primate_humor +009_dark_primate_humor +010_corp_humor_skill +011_update_agents_template +012_feral_capuchin_soul +013_compliance_cache_fix +014_monkey_behavior +017_add_yandex_oauth_skill +018_add_doc_translator_skill +018_docs_boundary +019_welcome_on_capabilities +023_add_gitlab_monitor_skill +024_add_new_shared_skills diff --git a/instances/admin/Dockerfile b/instances/admin/Dockerfile index 4cc8515..fef2f28 100644 --- a/instances/admin/Dockerfile +++ b/instances/admin/Dockerfile @@ -1,4 +1,4 @@ -FROM nousresearch/hermes-agent:stable +FROM nousresearch/hermes-agent:v0.22.5 USER root @@ -13,7 +13,7 @@ RUN apt-get update -qq && \ https://download.docker.com/linux/debian $(lsb_release -cs) stable" > \ /etc/apt/sources.list.d/docker.list && \ apt-get update -qq && \ - apt-get install -y --no-install-recommends docker-ce-cli docker-compose-plugin && \ + apt-get install -y --no-install-recommends docker-compose-plugin && \ rm -rf /var/lib/apt/lists/* # Пользователь внутри контейнера маппится на этапе запуска # через HERMES_UID/HERMES_GID (s6-overlay stage2), USER в конце не ставим. diff --git a/scripts/add-user.sh b/scripts/add-user.sh index 2f861aa..a829803 100644 --- a/scripts/add-user.sh +++ b/scripts/add-user.sh @@ -28,10 +28,19 @@ if [ -n "$TG_ID" ] && ! echo "$TG_ID" | grep -qE '^[0-9]+$'; then exit 1 fi -# Авто-порт: считаем существующие инстансы и берём 18790+N +# TG_ID обязателен: пустой allowlist + deny-by-default = бот молча игнорирует всех +if [ -z "$TG_ID" ]; then + echo "❌ TG_ID= обязателен (иначе бот будет глух ко всем)" + exit 1 +fi + +# Авто-порт: первый свободный 18790+N (проверяем занятость в override) if [ -z "$UI_PORT" ]; then - INSTANCE_COUNT=$(find instances -mindepth 1 -maxdepth 1 -type d ! -name "_template" ! -name "admin" | wc -l | tr -d ' ') - UI_PORT=$((18790 + INSTANCE_COUNT)) + OVERRIDE_CHECK="docker-compose.override.yml" + UI_PORT=18790 + while grep -q ":${UI_PORT}:9119" "$OVERRIDE_CHECK" 2>/dev/null; do + UI_PORT=$((UI_PORT + 1)) + done fi TEMPLATE="instances/_template" TARGET="instances/$NAME" @@ -62,6 +71,9 @@ if [ ! -d "$INSTANCE_WORKSPACE" ]; then fi mkdir -p "$INSTANCE_WORKSPACE/skills" "$INSTANCE_WORKSPACE/memory" +# Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), шаблон живёт в workspace +cp "$INSTANCE_WORKSPACE/SOUL.md" "$TARGET/hermes_data/SOUL.md" + # Подставляем переменные в workspace (безопасно для спецсимволов в FULL_NAME) python3 -c " import sys, pathlib @@ -116,13 +128,17 @@ docker run --rm \ -v "$(pwd)/$TARGET/workspace:/data2" \ alpine chown -R 1000:1000 /data1 /data2 -# Добавляем ключи в глобальный .env -if [ -f ".env" ]; then - echo "BROKER_KEY_${NAME_UPPER}=${BROKER_KEY}" >> .env - echo "QUOTA_KEY_${NAME_UPPER}=${QUOTA_KEY}" >> .env - echo "QUOTA_LIMIT_${NAME_UPPER}=1000000" >> .env # 1M токенов/мес по умолчанию - echo " ✅ Ключи добавлены в .env" +# Добавляем ключи в глобальный .env — без этого quota-proxy не узнает инстанс +if [ ! -f ".env" ]; then + echo "❌ Корневой .env не найден — QUOTA_KEY не будет зарегистрирован в quota-proxy" + exit 1 fi +# Гарантия завершающего перевода строки перед append +[ -s .env ] && [ -n "$(tail -c1 .env)" ] && echo >> .env +echo "BROKER_KEY_${NAME_UPPER}=${BROKER_KEY}" >> .env +echo "QUOTA_KEY_${NAME_UPPER}=${QUOTA_KEY}" >> .env +echo "QUOTA_LIMIT_${NAME_UPPER}=1000000" >> .env # 1M токенов/мес по умолчанию +echo " ✅ Ключи добавлены в .env" # Добавляем сервис в docker-compose.override.yml (gitignored — не конфликтует с git pull) python3 - "$NAME" "$NAME_UPPER" "$UI_PORT" << 'PYEOF' @@ -189,6 +205,9 @@ p.write_text(content) print(f" ✅ assistant-{name} добавлен в {OVERRIDE}") PYEOF +# Валидация итогового compose — ловим YAML-ошибки до деплоя +docker compose config -q && echo " ✅ docker compose config валиден" + echo "" echo "✅ Инстанс '$NAME' создан" echo " Квота: 1,000,000 токенов/мес (изменить: make set-limit NAME=$NAME LIMIT=500000)" @@ -196,5 +215,5 @@ echo " Dashboard: http://localhost:$UI_PORT" echo "" echo "📋 Далее:" echo " 1. nano instances/$NAME/.env — персональные токены" -echo " 2. nano instances/$NAME/workspace/SOUL.md — настроить персонажа" +echo " 2. nano instances/$NAME/hermes_data/SOUL.md — настроить персонажа (копия из workspace/SOUL.md)" echo " 3. make deploy" diff --git a/scripts/backup.sh b/scripts/backup.sh index b0533d0..7ca4b64 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -30,7 +30,10 @@ fi # ─── 2. Архив instances/ ───────────────────────────────────────────────────── tar -czf "$ARCHIVE" \ --exclude="instances/*/hermes_data/sessions/*" \ + --exclude="instances/*/.env" \ + --exclude="instances/*/hermes_data/.env" \ instances/ 2>&1 +chmod 600 "$ARCHIVE" # в воркспейсах нет секретов, но права — минимальные SIZE=$(du -sh "$ARCHIVE" | cut -f1) CHANGES+=("архив: $ARCHIVE ($SIZE)") diff --git a/scripts/fix-permissions.sh b/scripts/fix-permissions.sh index 2685e56..705067e 100644 --- a/scripts/fix-permissions.sh +++ b/scripts/fix-permissions.sh @@ -12,6 +12,14 @@ for f in instances/*/.env instances/*/hermes_data/config.yaml instances/*/hermes [ -f "$f" ] && chmod 600 "$f" && echo " ✅ $f → 600" done +# Hermes-данные — владелец 1000:1000 (HERMES_UID/GID), иначе агент не прочитает свой конфиг +for d in instances/*/hermes_data; do + if [ -d "$d" ]; then + chown -R 1000:1000 "$d" 2>/dev/null || sudo chown -R 1000:1000 "$d" || true + echo " ✅ $d → 1000:1000" + fi +done + # Скрипты — исполняемые for f in scripts/*.sh; do [ -f "$f" ] && chmod 755 "$f" && echo " ✅ $f → 755" diff --git a/scripts/migrate-instances.py b/scripts/migrate-instances.py index 6bdc341..970a850 100644 --- a/scripts/migrate-instances.py +++ b/scripts/migrate-instances.py @@ -42,23 +42,11 @@ def mark_applied(workspace, migration_id): applied.add(migration_id) f.write_text("\n".join(sorted(applied)) + "\n") -def _workspace_parent(): - """Resolve parent directory containing external workspaces. - - Inside the admin container WORKSPACE_PARENT=/infra-parent. - On the host, falls back to REPO_ROOT.parent. - """ - env = os.environ.get("WORKSPACE_PARENT") - if env: - return pathlib.Path(env) - return REPO_ROOT.parent - def _discover_instances(): """Find instances and their workspace paths. Primary: instances//workspace/ (in-repo). """ - parent = _workspace_parent() result = [] for d in sorted(INSTANCES.iterdir()): if not d.is_dir() or d.name == "_template": @@ -94,7 +82,11 @@ def run(): print(f" [{instance_dir.name}] применяю {len(pending)} миграций:") instance_updated = False for mig_path in pending: - mod = load_migration(mig_path) + try: + mod = load_migration(mig_path) + except Exception as e: + print(f" ❌ {mig_path.stem}: загрузка не удалась — {e}") + continue mid = mig_path.stem desc = getattr(mod, "DESCRIPTION", mid) try: diff --git a/scripts/provision-hermes-data.sh b/scripts/provision-hermes-data.sh new file mode 100755 index 0000000..a1fbab2 --- /dev/null +++ b/scripts/provision-hermes-data.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# provision-hermes-data.sh — гарантирует hermes_data/ для инстансов: +# каталог, .env с Hermes-секретами, SOUL.md, права 1000:1000 (HERMES_UID/GID). +# Использование: bash scripts/provision-hermes-data.sh [NAME ...] (без аргументов — все) +# Вызывается из `make bootstrap`; идемпотентно. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +INSTANCES="$ROOT/instances" + +provision() { + local NAME="$1" DIR="$INSTANCES/$NAME" + [ -d "$DIR" ] || { echo "❌ $DIR не найден"; exit 1; } + mkdir -p "$DIR/hermes_data/ssh" + + # .env для Hermes — из instance .env (поддержка старых и новых имён переменных) + if [ ! -f "$DIR/hermes_data/.env" ] && [ -f "$DIR/.env" ]; then + tg_token=$(grep -E '^TELEGRAM_BOT_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2-) + tg_users=$(grep -E '^TELEGRAM_ALLOWED_USERS=' "$DIR/.env" | head -1 | cut -d= -f2-) + [ -z "$tg_users" ] && tg_users=$(grep -E '^TELEGRAM_ALLOW_FROM=' "$DIR/.env" | head -1 | cut -d= -f2-) + gw=$(grep -E '^HERMES_GATEWAY_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2-) + [ -z "$gw" ] && gw=$(grep -E '^GATEWAY_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2-) + ak=$(grep -E '^ANTHROPIC_API_KEY=' "$DIR/.env" | head -1 | cut -d= -f2-) + # fallback: root .env → ANTHROPIC_API_KEY (admin), затем QUOTA_KEY_ (user) + if [ -z "$ak" ] && [ -f "$ROOT/.env" ]; then + ak=$(grep -E '^ANTHROPIC_API_KEY=' "$ROOT/.env" | head -1 | cut -d= -f2-) + [ -z "$ak" ] && ak=$(grep -E "^QUOTA_KEY_$(echo "$NAME" | tr '[:lower:]' '[:upper:]')=" "$ROOT/.env" | head -1 | cut -d= -f2-) + fi + { + echo "TELEGRAM_BOT_TOKEN=${tg_token:-}" + echo "TELEGRAM_ALLOWED_USERS=${tg_users:-}" + echo "HERMES_GATEWAY_TOKEN=${gw:-}" + echo "ANTHROPIC_API_KEY=${ak:-}" + echo "# OPENAI_API_KEY= # для voice.stt.provider: openai" + } > "$DIR/hermes_data/.env" + chmod 600 "$DIR/hermes_data/.env" + echo " [$NAME] hermes_data/.env создан" + fi + + # Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), шаблон живёт в workspace + if [ -f "$DIR/workspace/SOUL.md" ] && [ ! -f "$DIR/hermes_data/SOUL.md" ]; then + cp "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" + echo " [$NAME] SOUL.md скопирован в hermes_data/" + fi + + # Права: HERMES_UID/GID=1000 — иначе s6-процесс не сможет писать в свой home + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker run --rm -v "$DIR/hermes_data:/data" alpine chown -R 1000:1000 /data + else + chown -R 1000:1000 "$DIR/hermes_data" 2>/dev/null \ + || sudo chown -R 1000:1000 "$DIR/hermes_data" + fi + echo " [$NAME] ✅ provisioned" +} + +if [ $# -eq 0 ]; then + for d in "$INSTANCES"/*/; do + n=$(basename "$d") + [ "$n" = "_template" ] && continue + provision "$n" + done +else + for n in "$@"; do + provision "$n" + done +fi diff --git a/scripts/refresh-anthropic-token.sh b/scripts/refresh-anthropic-token.sh deleted file mode 100755 index 9da4208..0000000 --- a/scripts/refresh-anthropic-token.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" - -ENV_FILE="${PROJECT_DIR}/.env" -LOG_FILE="${PROJECT_DIR}/logs/token-refresh.log" -mkdir -p "$(dirname "$LOG_FILE")" - -log() { echo "[$(date -u '+%Y-%m-%d %H:%M:%S UTC')] $*" | tee -a "$LOG_FILE"; } - -REFRESH_TOKEN=$(grep '^ANTHROPIC_REFRESH_TOKEN=' "$ENV_FILE" | cut -d= -f2-) - -if [ -z "$REFRESH_TOKEN" ]; then - log "ERROR: ANTHROPIC_REFRESH_TOKEN не найден в .env" - exit 1 -fi - -log "Обновляем Anthropic OAuth token..." - -RESPONSE=$(curl -s -X POST "https://claude.ai/api/auth/oauth/token" \ - -H "Content-Type: application/json" \ - -d "{\"grant_type\":\"refresh_token\",\"refresh_token\":\"${REFRESH_TOKEN}\"}" \ - --max-time 30) - -NEW_ACCESS_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['access_token'])" 2>/dev/null || true) -NEW_REFRESH_TOKEN=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('refresh_token',''))" 2>/dev/null || true) -EXPIRES_AT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('expires_at', d.get('expires_in','')))" 2>/dev/null || true) - -if [ -z "$NEW_ACCESS_TOKEN" ]; then - log "ERROR: не удалось получить новый токен. Ответ: $RESPONSE" - exit 1 -fi - -log "Получен новый access token (${NEW_ACCESS_TOKEN:0:20}...)" - -TMP_ENV=$(mktemp) -grep -v '^ANTHROPIC_API_KEY=' "$ENV_FILE" | grep -v '^ANTHROPIC_REFRESH_TOKEN=' | grep -v '^ANTHROPIC_TOKEN_EXPIRES_AT=' > "$TMP_ENV" -echo "ANTHROPIC_API_KEY=${NEW_ACCESS_TOKEN}" >> "$TMP_ENV" -echo "ANTHROPIC_REFRESH_TOKEN=${NEW_REFRESH_TOKEN:-$REFRESH_TOKEN}" >> "$TMP_ENV" -echo "ANTHROPIC_TOKEN_EXPIRES_AT=${EXPIRES_AT}" >> "$TMP_ENV" -mv "$TMP_ENV" "$ENV_FILE" - -log "Обновлён .env. Перезапускаем quota-proxy..." -cd "$PROJECT_DIR" && docker compose restart quota-proxy >> "$LOG_FILE" 2>&1 -log "Готово." diff --git a/scripts/refresh_token.py b/scripts/refresh_token.py deleted file mode 100755 index 97316eb..0000000 --- a/scripts/refresh_token.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 -""" -Автообновление Anthropic OAuth токена для CortexForge. -Endpoint: https://api.anthropic.com/v1/oauth/token -Токен живёт 8 часов — обновляем за 1 час до истечения. -""" -import json, re, subprocess, urllib.request, urllib.error, pathlib, sys, time - -TOKEN_FILE = pathlib.Path("/opt/cortex-forge/.anthropic_tokens.json") -COMPOSE = "/opt/cortex-forge" -INSTANCES = ["admin", "user-1", "user-3", "user-4"] -SERVICES = ["assistant-admin", "assistant-user-1", "assistant-user-3", "assistant-user-4"] - -ENDPOINT = "https://api.anthropic.com/v1/oauth/token" -CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" - -# Обновляем если < 3600 секунд (1 час) до истечения -REFRESH_THRESHOLD = 3600 - - -def load(): - return json.loads(TOKEN_FILE.read_text()) - - -def save(data): - TOKEN_FILE.write_text(json.dumps(data, indent=2)) - TOKEN_FILE.chmod(0o600) - - -def needs_refresh(tokens): - expires_ms = tokens.get("expiresAt", 0) - remaining = (expires_ms / 1000) - time.time() - hours = remaining / 3600 - print(f"Токен истекает через {hours:.1f}ч") - return remaining < REFRESH_THRESHOLD - - -def do_refresh(refresh_token): - payload = json.dumps({ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - }).encode() - - req = urllib.request.Request( - ENDPOINT, data=payload, - headers={ - "Content-Type": "application/json", - "anthropic-version": "2023-06-01", - "User-Agent": "claude-code/2.1.63", - }, - method="POST" - ) - with urllib.request.urlopen(req, timeout=15) as r: - return json.loads(r.read()) - - -def update_env(new_token): - paths = [pathlib.Path("/opt/cortex-forge/.env")] + [ - pathlib.Path(f"/opt/cortex-forge/instances/{n}/.env") for n in INSTANCES - ] - for p in paths: - if not p.exists(): - continue - c = p.read_text() - c = re.sub( - r"ANTHROPIC_API_KEY=sk-ant-[^\n]+", - f"ANTHROPIC_API_KEY={new_token}", - c - ) - p.write_text(c) - print("✅ Все .env обновлены") - - -def restart(): - subprocess.run( - ["docker", "compose", "up", "-d"] + SERVICES, - cwd=COMPOSE, check=True, capture_output=True - ) - print("✅ Контейнеры перезапущены") - - -if __name__ == "__main__": - tokens = load() - - force = "--force" in sys.argv - if not force and not needs_refresh(tokens): - print("Токен свежий, ничего делать не нужно") - sys.exit(0) - - print("Обновляем токен...") - try: - result = do_refresh(tokens["refreshToken"]) - - new_access = result["access_token"] - new_refresh = result.get("refresh_token", tokens["refreshToken"]) - expires_in = result.get("expires_in", 28800) - - tokens["accessToken"] = new_access - tokens["refreshToken"] = new_refresh - tokens["expiresAt"] = int((time.time() + expires_in) * 1000) - save(tokens) - - update_env(new_access) - restart() - - hours = expires_in / 3600 - print(f"✅ Токен обновлён, истекает через {hours:.0f}ч") - - except Exception as e: - print(f"❌ Ошибка: {e}") - sys.exit(1) diff --git a/scripts/sync-instances.sh b/scripts/sync-instances.sh index ac10357..0d55618 100755 --- a/scripts/sync-instances.sh +++ b/scripts/sync-instances.sh @@ -8,7 +8,6 @@ set -euo pipefail INFRA="/infra" INSTANCES="$INFRA/instances" ADMIN_INSTANCE="admin" -WORKSPACE_PARENT="${WORKSPACE_PARENT:-$INFRA/..}" CHANGES=() # ─── 1. Миграции ──────────────────────────────────────────────────────────── @@ -25,7 +24,7 @@ for INST_DIR in "$INSTANCES"/*/; do [ "$NAME" = "_template" ] && continue [ "$NAME" = "$ADMIN_INSTANCE" ] && continue # у Admin свой HEARTBEAT - WS="$WORKSPACE_PARENT/${NAME}-workspace" + WS="$INSTANCES/$NAME/workspace" [ -d "$WS" ] || continue HB="$WS/HEARTBEAT.md" From 0cdeceaff1520584cf04ac58c6477a7f80c3eab8 Mon Sep 17 00:00:00 2001 From: nikita42 <13642481+rekurt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:32:19 +0300 Subject: [PATCH 3/5] fix: resolve round-2 review findings and shellcheck warnings --- .env.example | 1 + .gitignore | 5 +- CLAUDE.md | 7 +- CONTRIBUTING.md | 14 ++-- Makefile | 2 +- README.md | 6 +- README.ru.md | 6 +- docs/SETUP.md | 71 +++++++++------- docs/migration-hermes.md | 49 +++++++---- instances/admin/workspace/.migrations_applied | 20 +++++ scripts/add-user.sh | 84 +++++++++++-------- scripts/autoupdate.sh | 2 +- scripts/backup.sh | 11 ++- scripts/fix-permissions.sh | 14 ++-- scripts/migrate-instances.py | 6 ++ scripts/post-merge-hook.sh | 4 +- scripts/provision-hermes-data.sh | 78 ++++++++++++----- scripts/quota.sh | 1 + scripts/sync-instances.sh | 14 +++- 19 files changed, 261 insertions(+), 134 deletions(-) create mode 100644 instances/admin/workspace/.migrations_applied diff --git a/.env.example b/.env.example index 0c042eb..da69bf8 100644 --- a/.env.example +++ b/.env.example @@ -45,5 +45,6 @@ DADATA_SECRET=change-me-dadata-secret ENRICH_PY_PATH=./service-agent/skills/compliance/enrich_placeholder.py # ── Опционально ────────────────────────────────────────────────────────── +# OPENAI_API_KEY=sk-... # для транскрибации голосовых (voice.stt.provider: openai в Hermes) # GITLAB_TOKEN=glpat-... # GITLAB_URL=https://your-gitlab.example.com diff --git a/.gitignore b/.gitignore index 69b953c..637bdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -45,8 +45,11 @@ Thumbs.db # Greeting counter state shared/skills/corp-greeting/.greeting_counter -# Migration state per instance (local) +# Migration state per instance (local; template & admin baseline tracked) instances/*/.migrations_applied +instances/*/workspace/.migrations_applied +!instances/_template/workspace/.migrations_applied +!instances/admin/workspace/.migrations_applied # Instance workspace runtime data (not tracked) instances/*/workspace/memory/ diff --git a/CLAUDE.md b/CLAUDE.md index 26ae934..77b696e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -90,7 +90,7 @@ python3 -m pytest tests/test_broker_persistence.py::TestBrokerPersistence::test_ ### Security checks (required before PRs) ```bash -python3 .github/scripts/ai_security_check.py # 8 AI-specific security rules (Semgrep-like) +python3 .github/scripts/ai_security_check.py # 9 AI-specific security rules (Semgrep-like) shellcheck scripts/*.sh # shell script linting make security-check # .env-права (600), ключи, gitignore ``` @@ -276,7 +276,7 @@ instances//hermes_data/ # маунт → /opt/data (~/.hermes) ## CI/CD -- **`security.yml`** — Semgrep, CodeQL, Trivy, Gitleaks, Hadolint, ShellCheck + `ai_security_check.py` (8 кастомных правил) +- **`security.yml`** — Semgrep, CodeQL, Trivy, Gitleaks, Hadolint, ShellCheck + `ai_security_check.py` (9 кастомных правил) - **`lint-commits.yml`** — проверка Conventional Commits - **`release-please.yml`** — автоматически открывает PR с version bump и обновлением `CHANGELOG.md` - Правило: PR нельзя мёрджить с красными security-checks @@ -323,7 +323,8 @@ instances//hermes_data/ # маунт → /opt/data (~/.hermes) |--------|------------| | `scripts/autoupdate.sh` | Cron-скрипт (каждые 5 мин): git pull + rebuild changed | | `scripts/sync-instances.sh` | Ежечасный maintenance от admin: миграции, healthcheck | -| `scripts/fix-permissions.sh` | Восстановить права 600 на .env после git-операций | +| `scripts/fix-permissions.sh` | Восстановить права 600 на .env и 1000:1000 на hermes_data/workspace | +| `scripts/provision-hermes-data.sh` | Подготовить `hermes_data/` (каталоги, `.env`, `config.yaml`, `SOUL.md`, права) — вызывается из `make bootstrap` / `make deploy` | | `scripts/migrate-instances.py` | Применить миграции ко всем инстансам | ## Gotchas diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 280c49f..158247e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,16 +48,19 @@ Describe the problem you're solving, not just the solution. ```bash git clone https://github.com/example-org/cortex-forge.git -cd CortexForge +cd cortex-forge # Global config cp .env.example .env +chmod 600 .env # Edit .env: set ANTHROPIC_API_KEY and other tokens # Admin instance (required) mkdir -p instances/admin cp instances/.env.example instances/admin/.env -# Edit instances/admin/.env: set TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS +chmod 600 instances/admin/.env +# Edit instances/admin/.env: set TELEGRAM_BOT_TOKEN, TELEGRAM_ALLOWED_USERS, HERMES_GATEWAY_TOKEN +make bootstrap # Launch make deploy @@ -80,8 +83,7 @@ make security-check ### Running tests ```bash -# Tests are being added — see open issues tagged 'tests' -# pytest tests/ -v +python3 -m unittest discover tests/ -v ``` ### Adding a skill to service-agent @@ -94,7 +96,7 @@ make security-check # ... do work ... print(json.dumps({"result": "done"})) ``` -3. Add `skills.json` manifest (see `compliance/skills.json` as reference) +3. Add `skills.json` manifest (see `service-agent/skills/compliance/skills.json` as reference) 4. Rebuild: `docker compose build assistant-service` --- @@ -141,4 +143,4 @@ PRs are merged via **squash merge** to keep `master` history clean. **Do not open a public issue for security vulnerabilities.** -See [SECURITY.md](SECURITY.md) for responsible disclosure instructions. +See [docs/SECURITY.md](docs/SECURITY.md) for responsible disclosure instructions. diff --git a/Makefile b/Makefile index b90e4a4..4fccef9 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ bootstrap: # ── Деплой ──────────────────────────────────────────────────────────────── -deploy: +deploy: bootstrap docker compose up -d --build @echo "✅ Все инстансы запущены" diff --git a/README.md b/README.md index 5c1db0c..6a316ed 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,7 @@ mkdir -p instances/admin cp instances/.env.example instances/admin/.env chmod 600 instances/admin/.env nano instances/admin/.env +make bootstrap ``` Required fields: @@ -220,6 +221,7 @@ Required fields: ```bash TELEGRAM_BOT_TOKEN=0000000000:CHANGE_ME_BOT_TOKEN TELEGRAM_ALLOWED_USERS=000000000 # your Telegram user ID +HERMES_GATEWAY_TOKEN= # Hermes gateway auth token BROKER_KEY= # must match the global .env value ``` @@ -302,7 +304,8 @@ make status # all containers should be Up (healthy) |---|---| | `make add-user NAME=x BOT_TOKEN=y TG_ID=z` | Onboard a new employee | | `make remove-user NAME=x` | Remove instance (archives workspace to `backups/` first) | -| `make deploy` | Start / rebuild all containers | +| `make bootstrap` | Provision `hermes_data/` (`.env`, `config.yaml`, `SOUL.md`, permissions) for instances | +| `make deploy` | Run `bootstrap`, then start / rebuild all containers | | `make restart NAME=x` | Restart a single instance | | `make logs NAME=x` | Tail logs for an instance | | `make status` | Status of all containers | @@ -423,6 +426,7 @@ CortexForge/ ├── scripts/ │ ├── add-user.sh # onboarding: keys, template copy, docker-compose entry │ ├── remove-user.sh # remove instance with workspace archive +│ ├── provision-hermes-data.sh # bootstrap hermes_data/ (.env, config.yaml, SOUL.md, chown) │ ├── quota.sh # quota CLI (report / reset / set-limit) │ ├── monitor.sh # monitoring CLI │ ├── add-service.sh # add service-agent replica diff --git a/README.ru.md b/README.ru.md index 2ce53be..0331505 100644 --- a/README.ru.md +++ b/README.ru.md @@ -211,6 +211,7 @@ mkdir -p instances/admin cp instances/.env.example instances/admin/.env chmod 600 instances/admin/.env nano instances/admin/.env +make bootstrap ``` Обязательные поля: @@ -218,6 +219,7 @@ nano instances/admin/.env ```bash TELEGRAM_BOT_TOKEN=0000000000:CHANGE_ME_BOT_TOKEN TELEGRAM_ALLOWED_USERS=000000000 # Telegram user ID +HERMES_GATEWAY_TOKEN=<случайные-32-символа> # токен авторизации Hermes gateway BROKER_KEY=<то же что BROKER_KEY_ADMIN> # должно совпадать с глобальным .env ``` @@ -300,7 +302,8 @@ make status # все контейнеры должны быть Up (healthy) |---|---| | `make add-user NAME=x BOT_TOKEN=y TG_ID=z` | Онбординг нового сотрудника | | `make remove-user NAME=x` | Удалить инстанс (сначала архивирует воркспейс в `backups/`) | -| `make deploy` | Запустить / пересобрать все контейнеры | +| `make bootstrap` | Подготовить `hermes_data/` (`.env`, `config.yaml`, `SOUL.md`, права) для инстансов | +| `make deploy` | Выполнить `bootstrap`, затем запустить / пересобрать все контейнеры | | `make restart NAME=x` | Перезапустить один инстанс | | `make logs NAME=x` | Логи инстанса в реальном времени | | `make status` | Статус всех контейнеров | @@ -421,6 +424,7 @@ CortexForge/ ├── scripts/ │ ├── add-user.sh # онбординг: ключи, копирование шаблона, docker-compose │ ├── remove-user.sh # удаление инстанса с архивом воркспейса +│ ├── provision-hermes-data.sh # подготовка hermes_data/ (.env, config.yaml, SOUL.md, chown) │ ├── quota.sh # CLI квот (report / reset / set-limit) │ ├── monitor.sh # CLI мониторинга │ ├── add-service.sh # добавить реплику service-agent diff --git a/docs/SETUP.md b/docs/SETUP.md index b2c25cd..ae45efc 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -32,8 +32,8 @@ docker compose version # Docker Compose version 2.x ## 2. Клонирование репозитория ```bash -git clone https://github.com/example-org/cortex-forge.git /opt/CortexForge -cd /opt/CortexForge +git clone https://github.com/example-org/cortex-forge.git /opt/cortex-forge +cd /opt/cortex-forge ``` --- @@ -50,37 +50,44 @@ nano .env | Ключ | Описание | |---|---| -| `ANTHROPIC_API_KEY` | API-ключ Anthropic (`sk-ant-…`) | +| `ANTHROPIC_API_KEY` | API-ключ Anthropic (`sk-ant-…`), обязателен для admin | +| `CLIPROXY_API_KEY` | Ключ авторизации quota-proxy → CLIProxyAPI (`clip-…`) | | `QUOTA_ADMIN_TOKEN` | Токен управления квотами (мин. 32 символа) | | `BROKER_KEY_ADMIN` | Ключ admin-инстанса в брокере | -| `MONITOR_API_KEY` | Bearer-токен для API resource-monitor | -| `SERVICE_API_KEY` | Bearer-токен для service-agent | +| `BROKER_KEY_SERVICE` | Ключ service-agent в брокере | +| `BROKER_KEY_MONITOR` | Ключ resource-monitor в брокере | +| `MONITOR_ADMIN_TOKEN` | Bearer-токен для API resource-monitor | +| `SERVICE_API_KEY` | Bearer-токен для service-agent (`svc-…`) | Сгенерировать случайные токены: ```bash python3 -c "import secrets; print(secrets.token_urlsafe(32))" +python3 -c "import secrets; print('clip-' + secrets.token_urlsafe(24))" +python3 -c "import secrets; print('svc-' + secrets.token_urlsafe(24))" ``` --- ## 4. Admin-инстанс (Admin) -Admin-инстанс обязателен — `docker-compose.yml` требует `instances/admin/.env` при старте. +Admin-инстанс обязателен — `docker-compose.yml` требует `instances/admin/.env` и `instances/admin/hermes_data` при старте. ```bash mkdir -p instances/admin cp instances/.env.example instances/admin/.env chmod 600 instances/admin/.env nano instances/admin/.env +make bootstrap ``` Заполнить в `instances/admin/.env`: | Ключ | Описание | |---|---| -| `TELEGRAM_BOT_TOKEN` | Placeholder токена Telegram-бота Admin | -| `TELEGRAM_ALLOWED_USERS` | Telegram ID администратора | -| `BROKER_KEY` | То же значение что `BROKER_KEY_ADMIN` в `.env` | +| `TELEGRAM_BOT_TOKEN` | Токен Telegram-бота Admin | +| `TELEGRAM_ALLOWED_USERS` | Telegram ID администратора (числовой, через запятую) | +| `HERMES_GATEWAY_TOKEN` | Токен авторизации Hermes gateway (`secrets.token_urlsafe(32)`) | +| `BROKER_KEY` | Ключ брокера (в compose перекрывается `BROKER_KEY_ADMIN` из корневого `.env`) | --- @@ -107,25 +114,26 @@ nano instances/user-2/.env ``` Скрипт автоматически: -- Создаёт `instances/user-2/` из шаблона -- Генерирует уникальные `BROKER_KEY` и `QUOTA_KEY` -- Добавляет ключи в глобальный `.env` -- Выводит блок для `docker-compose.yml` +- Создаёт `instances/user-2/` (`workspace/` и `hermes_data/`) из шаблона +- Генерирует уникальные `BROKER_KEY`, `QUOTA_KEY` и `HERMES_GATEWAY_TOKEN` +- Добавляет ключи в глобальный `.env` и `instances/user-2/hermes_data/.env` +- Дописывает сервис `assistant-user-2` в `docker-compose.override.yml` --- ## 7. Деплой ```bash -make deploy +make deploy # запускает make bootstrap + docker compose up -d --build ``` Проверка: ```bash -make status # все контейнеры Up (healthy) -make quota-report # должно показать инстансы с 0 токенов -make monitor # метрики CPU/RAM/диска +make status # все контейнеры Up (healthy) +make quota-report # должно показать инстансы с 0 токенов +make monitor # метрики CPU/RAM/диска +curl -sI http://127.0.0.1:18789 # Hermes Dashboard (admin) ``` --- @@ -135,23 +143,23 @@ make monitor # метрики CPU/RAM/диска ```bash cat > /etc/systemd/system/cortexforge.service << 'EOF' [Unit] -Description=Corp Assistant +Description=CortexForge Assistant Infrastructure After=docker.service Requires=docker.service [Service] -WorkingDirectory=/opt/CortexForge -ExecStart=docker compose up -ExecStop=docker compose down -Restart=on-failure -RestartSec=10 +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/cortex-forge +ExecStart=/usr/bin/docker compose up -d +ExecStop=/usr/bin/docker compose down [Install] WantedBy=multi-user.target EOF systemctl daemon-reload -systemctl enable --now CortexForge +systemctl enable --now cortexforge.service ``` --- @@ -160,7 +168,7 @@ systemctl enable --now CortexForge ```bash # Ежедневно в 2:00 -echo "0 2 * * * root cd /opt/CortexForge && make backup >> /var/log/cortexforge-backup.log 2>&1" \ +echo "0 2 * * * root cd /opt/cortex-forge && make backup >> /var/log/cortexforge-backup.log 2>&1" \ >> /etc/cron.d/cortexforge ``` @@ -171,7 +179,7 @@ echo "0 2 * * * root cd /opt/CortexForge && make backup >> /var/log/cortexforge- ## Обновление ```bash -cd /opt/CortexForge +cd /opt/cortex-forge git pull origin master make deploy # пересобирает изменившиеся образы, перезапускает ``` @@ -183,9 +191,10 @@ make deploy # пересобирает изменившиеся образы, ## Диагностика ```bash -make status # статус всех контейнеров -make logs NAME=quota-proxy # логи конкретного сервиса -make monitor # метрики ресурсов -make monitor-alerts # активные алерты -docker compose exec corp-admin bash # shell в admin-контейнер +make status # статус всех контейнеров +docker compose logs -f quota-proxy # логи инфраструктурного сервиса +make logs NAME=admin # логи конкретного ассистента (assistant-admin) +make monitor # метрики ресурсов +make monitor-alerts # активные алерты +docker compose exec assistant-admin bash # shell в admin-контейнер ``` diff --git a/docs/migration-hermes.md b/docs/migration-hermes.md index a382cdf..c319942 100644 --- a/docs/migration-hermes.md +++ b/docs/migration-hermes.md @@ -111,27 +111,42 @@ instances// Big-bang без импорта данных: откат = **повторный fresh-install на старой версии**. `git checkout <прошлый тег> && docker compose up -d` поднимает старый код, но старые `openclaw_data/` удалены — инстансы нужно пересоздать через `make add-user` с теми же -`BOT_TOKEN`/`TG_ID` (см. §8, шаг 1 — токены сохраняются до wipe). Квоты в quota-proxy +`BOT_TOKEN`/`TG_ID` (поэтому токены ботов должны храниться во внешнем менеджере секретов, +а не только на сервере — бэкапы `.env`-файлы не включают). Квоты в quota-proxy (SQLite) не зависят от агента и остаются. ## 8. Runbook деплоя на VPS -1. **Сохранить токены ДО wipe** — иначе Telegram-боты не восстановить: со старого хоста - забрать `instances/*/.env` и/или `openclaw_data/*/openclaw.json` (оттуда достаются - `BOT_TOKEN` и `allowFrom`). Хранить вне репо. -2. `git fetch && git checkout migrate/hermes` (после ревью — merge в master и pull). -3. `make bootstrap` — создаёт `instances/*/hermes_data` (+`.env` из старых имён переменных, - `SOUL.md`, права 1000:1000). -4. Пересоздать инстансы: `make add-user NAME=… BOT_TOKEN=… TG_ID=…` (токены из шага 1). -5. `docker compose build && docker compose up -d`. -6. Smoke-тест одного инстанса по чек-листу §6 + доп-проверки: - - dashboard открывается на `127.0.0.1:<порт>`; если connection refused — dashboard - биндится на loopback контейнера (проверить `--host`/config dashboard у Hermes); - - интерполяция `${VAR}` в config.yaml работает (`hermes status` внутри контейнера); - - фактический layout `hermes_data/sessions/` сверить с exclude в `backup.sh`; - - gateway API `8642` — если dashboard-UI ходит к API с хоста, опубликовать порт. -7. Повторить для остальных инстансов, удалить старые `openclaw_data/` и образы - `ghcr.io/openclaw/*`. +1. **Остановить cron автообновления (`autoupdate.sh`)** на время переключения + (`crontab -e` — закомментировать строку `autoupdate.sh`), иначе каждые 5 минут cron + будет делать `git pull origin master` и откатывать ветку/контейнеры. +2. **Сохранить токены во внешний менеджер секретов ДО любых удалений**: со старого хоста + забрать `instances/*/.env` и/или `instances/*/openclaw_data/openclaw.json` (`BOT_TOKEN`, + `allowFrom`). На самом сервере бэкапы `.env` больше не хранят. +3. Обновить код: `git fetch && git checkout master && git pull` (после мерджа `migrate/hermes`). +4. **Мигрировать существующие каталоги инстансов**: выполнить `make bootstrap` + (`scripts/provision-hermes-data.sh`). Для каждого существующего `instances//` скрипт + создаст `hermes_data/`, скопирует `config.yaml` из шаблона (для user-инстансов), + сгенерирует `hermes_data/.env` (перенеся `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS` + из старого `TELEGRAM_ALLOW_FROM`, `HERMES_GATEWAY_TOKEN` из `GATEWAY_TOKEN` и строго + разделив `ANTHROPIC_API_KEY` для admin vs `QUOTA_KEY_` для user-инстансов), + синхронизирует `SOUL.md` и выставит владельца `1000:1000`. + *(Команда `make add-user` используется только для добавления новых сотрудников, так как + отклоняет уже существующие каталоги `instances/`).* +5. Перегенерировать `docker-compose.override.yml` для пользовательских сервисов под схему + Hermes (маунты `hermes_data:/opt/data`, порт `9119`, `HERMES_UID/GID=1000`) либо + удалить старые каталоги `instances/` + `override` и пересоздать их через + `make add-user NAME=… BOT_TOKEN=… TG_ID=…` из сохранённых на шаге 2 токенов. +6. Запустить стек: `make deploy` (выполняет `make bootstrap` + `docker compose up -d --build`). +7. Smoke-тест одного инстанса по чек-листу §6 + обязательные проверки на живом контейнере: + - `docker compose exec assistant-admin id` → проверить, что `uid=1000(hermes)` (s6 ремап); + - `docker compose exec assistant-admin hermes status` → провайдер авторизован (интерполяция `${VAR}`); + - `curl -sI http://127.0.0.1:18789` → dashboard отвечает (если `connection refused`, + проверить bind-адрес dashboard внутри контейнера); + - сверить фактический путь/формат файлов в `hermes_data/sessions/` с exclude в `backup.sh`. +8. Удалить старые каталоги `instances/*/openclaw_data/` и образы `ghcr.io/openclaw/*` + (**не использовать** `docker system prune --volumes`, чтобы не снести `cliproxyapi-auths` + с OAuth-сессией Claude Max). Разморозить cron `autoupdate.sh`. ## 9. Оценка diff --git a/instances/admin/workspace/.migrations_applied b/instances/admin/workspace/.migrations_applied new file mode 100644 index 0000000..aa0dff5 --- /dev/null +++ b/instances/admin/workspace/.migrations_applied @@ -0,0 +1,20 @@ +001_add_corp_greeting +002_bolder_soul +003_capuchin_soul +004_corp_docs_skill +005_start_greeting +006_monkey_soul +007_less_brief +008_primate_humor +009_dark_primate_humor +010_corp_humor_skill +011_update_agents_template +012_feral_capuchin_soul +013_compliance_cache_fix +014_monkey_behavior +017_add_yandex_oauth_skill +018_add_doc_translator_skill +018_docs_boundary +019_welcome_on_capabilities +023_add_gitlab_monitor_skill +024_add_new_shared_skills diff --git a/scripts/add-user.sh b/scripts/add-user.sh index a829803..96c32a9 100644 --- a/scripts/add-user.sh +++ b/scripts/add-user.sh @@ -34,33 +34,39 @@ if [ -z "$TG_ID" ]; then exit 1 fi -# Авто-порт: первый свободный 18790+N (проверяем занятость в override) -if [ -z "$UI_PORT" ]; then - OVERRIDE_CHECK="docker-compose.override.yml" - UI_PORT=18790 - while grep -q ":${UI_PORT}:9119" "$OVERRIDE_CHECK" 2>/dev/null; do - UI_PORT=$((UI_PORT + 1)) - done -fi -TEMPLATE="instances/_template" -TARGET="instances/$NAME" - if [ "$NAME" = "admin" ]; then echo "❌ Имя 'admin' зарезервировано" exit 1 fi +TEMPLATE="instances/_template" +TARGET="instances/$NAME" + [ -d "$TARGET" ] && echo "❌ Инстанс '$NAME' уже существует" && exit 1 -# Проверяем существование директории шаблона if [ ! -d "$TEMPLATE/workspace" ]; then echo "❌ Директория шаблона не найдена: $TEMPLATE/workspace" exit 1 fi +# Fail-fast до создания файлов: без корневого .env quota-proxy не узнает инстанс +if [ ! -f ".env" ]; then + echo "❌ Корневой .env не найден (сделай cp .env.example .env) — QUOTA_KEY не будет зарегистрирован в quota-proxy" + exit 1 +fi + +# Авто-порт: первый свободный 18790+N (проверяем занятость в override) +if [ -z "$UI_PORT" ]; then + OVERRIDE_CHECK="docker-compose.override.yml" + UI_PORT=18790 + while grep -q ":${UI_PORT}:9119" "$OVERRIDE_CHECK" 2>/dev/null; do + UI_PORT=$((UI_PORT + 1)) + done +fi + echo "🚀 Создаём инстанс: $FULL_NAME ($NAME)" -mkdir -p "$TARGET/hermes_data" +mkdir -p "$TARGET/hermes_data/ssh" # Создаём workspace внутри директории инстанса INSTANCE_WORKSPACE="$TARGET/workspace" @@ -71,14 +77,11 @@ if [ ! -d "$INSTANCE_WORKSPACE" ]; then fi mkdir -p "$INSTANCE_WORKSPACE/skills" "$INSTANCE_WORKSPACE/memory" -# Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), шаблон живёт в workspace -cp "$INSTANCE_WORKSPACE/SOUL.md" "$TARGET/hermes_data/SOUL.md" - # Подставляем переменные в workspace (безопасно для спецсимволов в FULL_NAME) python3 -c " import sys, pathlib name, full_name, ws = sys.argv[1], sys.argv[2], sys.argv[3] -for fname in ['USER.md', 'IDENTITY.md']: +for fname in ['USER.md', 'IDENTITY.md', 'SOUL.md']: p = pathlib.Path(ws) / fname if p.exists(): text = p.read_text() @@ -86,6 +89,9 @@ for fname in ['USER.md', 'IDENTITY.md']: p.write_text(text) " "$NAME" "$FULL_NAME" "$INSTANCE_WORKSPACE" +# Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), копируем после подстановки плейсхолдеров +cp "$INSTANCE_WORKSPACE/SOUL.md" "$TARGET/hermes_data/SOUL.md" + # Генерируем ключи BROKER_KEY=$(python3 -c "import secrets; print(secrets.token_urlsafe(32))") QUOTA_KEY=$(python3 -c "import secrets; print('quota-${NAME}-' + secrets.token_urlsafe(24))") @@ -110,6 +116,7 @@ chmod 600 "$TARGET/.env" # Hermes конфиг и секреты — кладём в hermes_data/ (маунтится в /opt/data) cp "$TEMPLATE/hermes_config.yaml.template" "$TARGET/hermes_data/config.yaml" +chmod 600 "$TARGET/hermes_data/config.yaml" cat > "$TARGET/hermes_data/.env" << EOF # Секреты Hermes — НЕ коммитить в git! @@ -122,22 +129,26 @@ EOF chmod 600 "$TARGET/hermes_data/.env" # Выставляем владельца — HERMES_UID/HERMES_GID = 1000 (совпадает с chown). -# Используем docker alpine для chown, т.к. deploy user может не иметь прав на chown. -docker run --rm \ - -v "$(pwd)/$TARGET/hermes_data:/data1" \ - -v "$(pwd)/$TARGET/workspace:/data2" \ - alpine chown -R 1000:1000 /data1 /data2 - -# Добавляем ключи в глобальный .env — без этого quota-proxy не узнает инстанс -if [ ! -f ".env" ]; then - echo "❌ Корневой .env не найден — QUOTA_KEY не будет зарегистрирован в quota-proxy" - exit 1 +if chown -R 1000:1000 "$TARGET/hermes_data" "$TARGET/workspace" 2>/dev/null; then + : +elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + docker run --rm \ + -v "$(pwd)/$TARGET/hermes_data:/data1" \ + -v "$(pwd)/$TARGET/workspace:/data2" \ + alpine chown -R 1000:1000 /data1 /data2 +elif sudo -n chown -R 1000:1000 "$TARGET/hermes_data" "$TARGET/workspace" 2>/dev/null; then + : +else + echo " ⚠️ Не удалось выставить chown 1000:1000 (выполни make fix-permissions или make bootstrap под root)" fi -# Гарантия завершающего перевода строки перед append + +# Гарантия завершающего перевода строки перед append в глобальный .env [ -s .env ] && [ -n "$(tail -c1 .env)" ] && echo >> .env -echo "BROKER_KEY_${NAME_UPPER}=${BROKER_KEY}" >> .env -echo "QUOTA_KEY_${NAME_UPPER}=${QUOTA_KEY}" >> .env -echo "QUOTA_LIMIT_${NAME_UPPER}=1000000" >> .env # 1M токенов/мес по умолчанию +{ + echo "BROKER_KEY_${NAME_UPPER}=${BROKER_KEY}" + echo "QUOTA_KEY_${NAME_UPPER}=${QUOTA_KEY}" + echo "QUOTA_LIMIT_${NAME_UPPER}=1000000" # 1M токенов/мес по умолчанию +} >> .env echo " ✅ Ключи добавлены в .env" # Добавляем сервис в docker-compose.override.yml (gitignored — не конфликтует с git pull) @@ -164,7 +175,7 @@ service = f""" - ./shared/compliance-data:/shared/compliance-data:ro environment: - ANTHROPIC_API_KEY=${{{f"QUOTA_KEY_{NAME_UPPER}"}}} - - OPENAI_API_KEY=${{OPENAI_API_KEY}} + - OPENAI_API_KEY=${{OPENAI_API_KEY:-}} - SERVICE_API_KEY=${{SERVICE_API_KEY}} - BROKER_URL=http://message-broker:8080 - BROKER_KEY=${{{f"BROKER_KEY_{NAME_UPPER}"}}} @@ -196,7 +207,6 @@ service = f""" p = pathlib.Path(OVERRIDE) if p.exists(): content = p.read_text() - # append before end or after last service content = content.rstrip() + "\n" + service else: content = "services:" + service @@ -205,8 +215,10 @@ p.write_text(content) print(f" ✅ assistant-{name} добавлен в {OVERRIDE}") PYEOF -# Валидация итогового compose — ловим YAML-ошибки до деплоя -docker compose config -q && echo " ✅ docker compose config валиден" +# Валидация итогового compose (если установлен docker compose) +if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then + docker compose config -q && echo " ✅ docker compose config валиден" +fi echo "" echo "✅ Инстанс '$NAME' создан" @@ -215,5 +227,5 @@ echo " Dashboard: http://localhost:$UI_PORT" echo "" echo "📋 Далее:" echo " 1. nano instances/$NAME/.env — персональные токены" -echo " 2. nano instances/$NAME/hermes_data/SOUL.md — настроить персонажа (копия из workspace/SOUL.md)" +echo " 2. nano instances/$NAME/workspace/SOUL.md — настроить персонажа (синхронизируется при make bootstrap / deploy)" echo " 3. make deploy" diff --git a/scripts/autoupdate.sh b/scripts/autoupdate.sh index 85b7b07..8c71b9b 100755 --- a/scripts/autoupdate.sh +++ b/scripts/autoupdate.sh @@ -20,7 +20,7 @@ if ! flock -n 9; then exit 0 fi -cd "$REPO" +cd "$REPO" || exit 1 log() { echo "$(date -u '+%Y-%m-%dT%H:%M:%SZ') $*" | tee -a "$LOG"; } diff --git a/scripts/backup.sh b/scripts/backup.sh index 7ca4b64..5d7e7dc 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -7,6 +7,7 @@ # Из контейнера делаем только commit; push запускается отдельно на хосте. set -euo pipefail +umask 077 INFRA="/infra" BACKUPS="$INFRA/backups" @@ -27,20 +28,22 @@ else CHANGES+=("git: commit $TIMESTAMP") fi -# ─── 2. Архив instances/ ───────────────────────────────────────────────────── +# ─── 2. Архив instances/ (без сессий, .env-секретов и приватных SSH-ключей) ── tar -czf "$ARCHIVE" \ --exclude="instances/*/hermes_data/sessions/*" \ + --exclude="instances/*/hermes_data/ssh/*" \ --exclude="instances/*/.env" \ --exclude="instances/*/hermes_data/.env" \ instances/ 2>&1 -chmod 600 "$ARCHIVE" # в воркспейсах нет секретов, но права — минимальные +chmod 600 "$ARCHIVE" SIZE=$(du -sh "$ARCHIVE" | cut -f1) CHANGES+=("архив: $ARCHIVE ($SIZE)") # ─── 3. Ротация — оставляем только последние 48 архивов (~2 суток) ─────────── -ls -t "$BACKUPS"/instances_*.tar.gz 2>/dev/null | tail -n +49 | xargs -r rm -f -KEPT=$(ls "$BACKUPS"/instances_*.tar.gz 2>/dev/null | wc -l) +find "$BACKUPS" -maxdepth 1 -name 'instances_*.tar.gz' -type f -print0 2>/dev/null \ + | xargs -0 ls -t 2>/dev/null | tail -n +49 | xargs -r rm -f +KEPT=$(find "$BACKUPS" -maxdepth 1 -name 'instances_*.tar.gz' -type f 2>/dev/null | wc -l | tr -d ' ') CHANGES+=("ротация: сохранено $KEPT архивов") # ─── Итог ──────────────────────────────────────────────────────────────────── diff --git a/scripts/fix-permissions.sh b/scripts/fix-permissions.sh index 705067e..8a499c9 100644 --- a/scripts/fix-permissions.sh +++ b/scripts/fix-permissions.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# fix-permissions.sh — выставляет правильные права на секретные файлы +# fix-permissions.sh — выставляет правильные права на секретные файлы и воркспейсы set -e echo "🔒 Исправляем права файлов..." @@ -12,11 +12,15 @@ for f in instances/*/.env instances/*/hermes_data/config.yaml instances/*/hermes [ -f "$f" ] && chmod 600 "$f" && echo " ✅ $f → 600" done -# Hermes-данные — владелец 1000:1000 (HERMES_UID/GID), иначе агент не прочитает свой конфиг -for d in instances/*/hermes_data; do +# Hermes-данные и воркспейсы — владелец 1000:1000 (HERMES_UID/GID) +for d in instances/*/hermes_data instances/*/workspace; do + [ "$d" = "instances/_template/workspace" ] && continue if [ -d "$d" ]; then - chown -R 1000:1000 "$d" 2>/dev/null || sudo chown -R 1000:1000 "$d" || true - echo " ✅ $d → 1000:1000" + if chown -R 1000:1000 "$d" 2>/dev/null || sudo -n chown -R 1000:1000 "$d" 2>/dev/null; then + echo " ✅ $d → 1000:1000" + else + echo " ⚠️ $d: не удалось выставить chown 1000:1000 (запусти под root или через make bootstrap)" + fi fi done diff --git a/scripts/migrate-instances.py b/scripts/migrate-instances.py index 970a850..3ffedb5 100644 --- a/scripts/migrate-instances.py +++ b/scripts/migrate-instances.py @@ -7,6 +7,7 @@ import sys import importlib.util import pathlib +import shutil import os REPO_ROOT = pathlib.Path(__file__).parent.parent @@ -101,6 +102,11 @@ def run(): print(f" ❌ {mid}: {e}") if instance_updated: updated.append(instance_dir.name) + # Синхронизируем SOUL.md из workspace в hermes_data (~/.hermes/SOUL.md) + ws_soul = workspace / "SOUL.md" + hd_soul = instance_dir / "hermes_data" / "SOUL.md" + if ws_soul.exists() and hd_soul.parent.exists() and not DRY_RUN: + shutil.copyfile(ws_soul, hd_soul) # Сбрасываем кеш сессий — иначе агент продолжит работать # со старым SOUL.md/AGENTS.md из закешированной истории sessions_dir = instance_dir / "hermes_data" / "sessions" diff --git a/scripts/post-merge-hook.sh b/scripts/post-merge-hook.sh index 81bc04f..7b488b1 100644 --- a/scripts/post-merge-hook.sh +++ b/scripts/post-merge-hook.sh @@ -23,9 +23,6 @@ if echo "$CHANGED" | grep -qE '(\.gitmodules|shared/skills/)'; then git submodule update --init --recursive fi -RESTART="" -REBUILD="" - # Проверяем что изменилось echo "$CHANGED" | while read -r f; do case "$f" in @@ -85,6 +82,7 @@ if echo "$RESTART_LIST" | grep -q "user-instances"; then echo " 🔁 Обновились shared/skills — перезапускаю пользовательские инстансы..." USERS=$($COMPOSE ps --services 2>/dev/null | grep -v -E "^(assistant-admin|quota-proxy|message-broker|resource-monitor|assistant-service)$" | grep "assistant-" || true) if [ -n "$USERS" ]; then + # shellcheck disable=SC2086 $COMPOSE restart $USERS 2>&1 | grep -E "(Restarting|Started|error)" || true fi fi diff --git a/scripts/provision-hermes-data.sh b/scripts/provision-hermes-data.sh index a1fbab2..8c56ea5 100755 --- a/scripts/provision-hermes-data.sh +++ b/scripts/provision-hermes-data.sh @@ -1,58 +1,92 @@ #!/usr/bin/env bash # provision-hermes-data.sh — гарантирует hermes_data/ для инстансов: -# каталог, .env с Hermes-секретами, SOUL.md, права 1000:1000 (HERMES_UID/GID). +# каталог, config.yaml (для user-инстансов), .env с Hermes-секретами, +# SOUL.md, права 1000:1000 (HERMES_UID/GID). # Использование: bash scripts/provision-hermes-data.sh [NAME ...] (без аргументов — все) -# Вызывается из `make bootstrap`; идемпотентно. +# Вызывается из `make bootstrap` и `make deploy`; идемпотентно. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" INSTANCES="$ROOT/instances" +TEMPLATE="$INSTANCES/_template" provision() { - local NAME="$1" DIR="$INSTANCES/$NAME" - [ -d "$DIR" ] || { echo "❌ $DIR не найден"; exit 1; } + local NAME="$1" + local DIR="$INSTANCES/$NAME" + if [ ! -d "$DIR" ]; then + echo "⚠️ $DIR не найден — пропускаем" + return 0 + fi mkdir -p "$DIR/hermes_data/ssh" - # .env для Hermes — из instance .env (поддержка старых и новых имён переменных) + # Конфиг Hermes для user-инстансов (у admin свой instances/admin/config.yaml через bind-mount) + if [ "$NAME" != "admin" ] && [ ! -f "$DIR/hermes_data/config.yaml" ] && [ -f "$TEMPLATE/hermes_config.yaml.template" ]; then + cp "$TEMPLATE/hermes_config.yaml.template" "$DIR/hermes_data/config.yaml" + chmod 600 "$DIR/hermes_data/config.yaml" + echo " [$NAME] hermes_data/config.yaml создан из шаблона" + fi + + # .env для Hermes — из instance .env (поддержка старых и новых имён переменных; || true защищает от pipefail) if [ ! -f "$DIR/hermes_data/.env" ] && [ -f "$DIR/.env" ]; then - tg_token=$(grep -E '^TELEGRAM_BOT_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2-) - tg_users=$(grep -E '^TELEGRAM_ALLOWED_USERS=' "$DIR/.env" | head -1 | cut -d= -f2-) - [ -z "$tg_users" ] && tg_users=$(grep -E '^TELEGRAM_ALLOW_FROM=' "$DIR/.env" | head -1 | cut -d= -f2-) - gw=$(grep -E '^HERMES_GATEWAY_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2-) - [ -z "$gw" ] && gw=$(grep -E '^GATEWAY_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2-) - ak=$(grep -E '^ANTHROPIC_API_KEY=' "$DIR/.env" | head -1 | cut -d= -f2-) - # fallback: root .env → ANTHROPIC_API_KEY (admin), затем QUOTA_KEY_ (user) + local tg_token tg_users gw ak oai name_upper + name_upper=$(echo "$NAME" | tr '[:lower:]' '[:upper:]') + tg_token=$(grep -E '^TELEGRAM_BOT_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + tg_users=$(grep -E '^TELEGRAM_ALLOWED_USERS=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + [ -z "$tg_users" ] && tg_users=$(grep -E '^TELEGRAM_ALLOW_FROM=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + gw=$(grep -E '^HERMES_GATEWAY_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + [ -z "$gw" ] && gw=$(grep -E '^GATEWAY_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + ak=$(grep -E '^ANTHROPIC_API_KEY=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + oai=$(grep -E '^OPENAI_API_KEY=' "$DIR/.env" | head -1 | cut -d= -f2- || true) + + # Строгое разделение ключей: admin берёт реальный ANTHROPIC_API_KEY, user — только QUOTA_KEY_ if [ -z "$ak" ] && [ -f "$ROOT/.env" ]; then - ak=$(grep -E '^ANTHROPIC_API_KEY=' "$ROOT/.env" | head -1 | cut -d= -f2-) - [ -z "$ak" ] && ak=$(grep -E "^QUOTA_KEY_$(echo "$NAME" | tr '[:lower:]' '[:upper:]')=" "$ROOT/.env" | head -1 | cut -d= -f2-) + if [ "$NAME" = "admin" ]; then + ak=$(grep -E '^ANTHROPIC_API_KEY=' "$ROOT/.env" | head -1 | cut -d= -f2- || true) + else + ak=$(grep -E "^QUOTA_KEY_${name_upper}=" "$ROOT/.env" | head -1 | cut -d= -f2- || true) + fi fi + if [ -z "$oai" ] && [ -f "$ROOT/.env" ]; then + oai=$(grep -E '^OPENAI_API_KEY=' "$ROOT/.env" | head -1 | cut -d= -f2- || true) + fi + { echo "TELEGRAM_BOT_TOKEN=${tg_token:-}" echo "TELEGRAM_ALLOWED_USERS=${tg_users:-}" echo "HERMES_GATEWAY_TOKEN=${gw:-}" echo "ANTHROPIC_API_KEY=${ak:-}" - echo "# OPENAI_API_KEY= # для voice.stt.provider: openai" + if [ -n "${oai:-}" ]; then + echo "OPENAI_API_KEY=${oai}" + else + echo "# OPENAI_API_KEY= # для voice.stt.provider: openai" + fi } > "$DIR/hermes_data/.env" chmod 600 "$DIR/hermes_data/.env" echo " [$NAME] hermes_data/.env создан" fi - # Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), шаблон живёт в workspace - if [ -f "$DIR/workspace/SOUL.md" ] && [ ! -f "$DIR/hermes_data/SOUL.md" ]; then - cp "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" - echo " [$NAME] SOUL.md скопирован в hermes_data/" + # Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), источником истины служит workspace/SOUL.md + if [ -f "$DIR/workspace/SOUL.md" ]; then + if [ ! -f "$DIR/hermes_data/SOUL.md" ] || ! diff -q "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" >/dev/null 2>&1; then + cp "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" + echo " [$NAME] SOUL.md синхронизирован в hermes_data/" + fi fi # Права: HERMES_UID/GID=1000 — иначе s6-процесс не сможет писать в свой home - if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + if chown -R 1000:1000 "$DIR/hermes_data" 2>/dev/null; then + : + elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then docker run --rm -v "$DIR/hermes_data:/data" alpine chown -R 1000:1000 /data + elif sudo -n chown -R 1000:1000 "$DIR/hermes_data" 2>/dev/null; then + : else - chown -R 1000:1000 "$DIR/hermes_data" 2>/dev/null \ - || sudo chown -R 1000:1000 "$DIR/hermes_data" + echo " ⚠️ [$NAME] не удалось выставить 1000:1000 на $DIR/hermes_data (нужен root или работающий Docker)" fi echo " [$NAME] ✅ provisioned" } +shopt -s nullglob if [ $# -eq 0 ]; then for d in "$INSTANCES"/*/; do n=$(basename "$d") diff --git a/scripts/quota.sh b/scripts/quota.sh index a2499f9..ff2afdc 100644 --- a/scripts/quota.sh +++ b/scripts/quota.sh @@ -6,6 +6,7 @@ # bash scripts/quota.sh reset [YYYY-MM] set -e +# shellcheck source=/dev/null [ -f .env ] && source .env 2>/dev/null || true PROXY_URL="${QUOTA_PROXY_URL:-http://quota-proxy:9090}" ADMIN_TOKEN="${QUOTA_ADMIN_TOKEN:-changeme}" diff --git a/scripts/sync-instances.sh b/scripts/sync-instances.sh index 0d55618..66ce2be 100755 --- a/scripts/sync-instances.sh +++ b/scripts/sync-instances.sh @@ -16,17 +16,27 @@ if echo "$MIGRATE_OUT" | grep -qv "актуален"; then CHANGES+=("Миграции: $MIGRATE_OUT") fi -# ─── 2. Эталонный HEARTBEAT (берём из шаблона) ──────────────────────────── +# ─── 2. Эталонный HEARTBEAT и синхронизация SOUL.md → hermes_data/ ────────── HEARTBEAT_REF="$INSTANCES/_template/workspace/HEARTBEAT.md" for INST_DIR in "$INSTANCES"/*/; do NAME=$(basename "$INST_DIR") [ "$NAME" = "_template" ] && continue - [ "$NAME" = "$ADMIN_INSTANCE" ] && continue # у Admin свой HEARTBEAT WS="$INSTANCES/$NAME/workspace" + HD="$INSTANCES/$NAME/hermes_data" [ -d "$WS" ] || continue + # SOUL.md: источником истины служит workspace/SOUL.md, Hermes читает hermes_data/SOUL.md + if [ -f "$WS/SOUL.md" ] && [ -d "$HD" ]; then + if [ ! -f "$HD/SOUL.md" ] || ! diff -q "$WS/SOUL.md" "$HD/SOUL.md" > /dev/null 2>&1; then + cp "$WS/SOUL.md" "$HD/SOUL.md" + CHANGES+=("[$NAME] SOUL.md синхронизирован в hermes_data/") + fi + fi + + [ "$NAME" = "$ADMIN_INSTANCE" ] && continue # у Admin свой HEARTBEAT + HB="$WS/HEARTBEAT.md" if [ ! -f "$HB" ]; then cp "$HEARTBEAT_REF" "$HB" From c45bafbb3925900cd091c257033905f7483490b6 Mon Sep 17 00:00:00 2001 From: nikita42 <13642481+rekurt@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:32:19 +0300 Subject: [PATCH 4/5] chore: polish minor findings from round-3 review --- Makefile | 8 ++++---- README.md | 8 ++++++-- README.ru.md | 8 ++++++-- docker-compose.yml | 1 + instances/.env.example | 5 +++-- scripts/post-merge-hook.sh | 9 +++++++++ 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/Makefile b/Makefile index 4fccef9..08bc435 100644 --- a/Makefile +++ b/Makefile @@ -46,15 +46,15 @@ backup: # ── Безопасность ─────────────────────────────────────────────────────────── -# Проверка базовых требований безопасности +# Проверка базовых требований безопасности (поддержка GNU stat и BSD stat на macOS) security-check: @echo "🔒 Проверка безопасности..." - @[ -f .env ] && stat -c "%a" .env | grep -qE "^6[04]0$$" \ + @[ -f .env ] && (stat -c "%a" .env 2>/dev/null || stat -f "%Lp" .env 2>/dev/null) | grep -qE "^6[04]0$$" \ && echo " ✅ .env: права 600/640" \ || echo " ❌ .env: исправь права: chmod 600 .env" @find instances -name ".env" | while read f; do \ - mode=$$(stat -c "%a" $$f); \ - echo $$mode | grep -qE "^6[04]0$$" \ + mode=$$(stat -c "%a" "$$f" 2>/dev/null || stat -f "%Lp" "$$f" 2>/dev/null); \ + echo "$$mode" | grep -qE "^6[04]0$$" \ && echo " ✅ $$f: $${mode}" \ || echo " ❌ $$f: chmod 600 $$f"; \ done diff --git a/README.md b/README.md index 6a316ed..2147c58 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ This script automatically: Then optionally fill in personal credentials: ```bash nano instances/user-2/.env # Yandex OAuth, GitLab token, etc. -nano instances/user-2/workspace/SOUL.md # persona style and character +nano instances/user-2/workspace/SOUL.md # persona style (synced to hermes_data/SOUL.md on make bootstrap / make deploy) ``` ### 6. Launch @@ -270,9 +270,12 @@ make status # all containers should be Up (healthy) | Variable | Required | Description | |---|---|---| | `ANTHROPIC_API_KEY` | ✅ | Anthropic API key used by the admin instance directly; generated instances should use quota keys through `quota-proxy` | +| `CLIPROXY_API_KEY` | ✅ | Auth key between `quota-proxy` and `cliproxyapi` (`clip-…`) | | `QUOTA_ADMIN_TOKEN` | ✅ | Bearer token for quota management API | | `QUOTA_DEFAULT_MONTHLY` | — | Default token limit per month (default: `1000000`) | | `BROKER_KEY_ADMIN` | ✅ | Admin broker key | +| `BROKER_KEY_SERVICE` | ✅ | Service-agent broker key | +| `BROKER_KEY_MONITOR` | ✅ | Resource-monitor broker key | | `MONITOR_ADMIN_TOKEN` | ✅ | Bearer token for resource-monitor API | | `SERVICE_API_KEY` | ✅ | Bearer token for service-agent API | | `ENRICH_PY_PATH` | — | Path to `enrich.py` on host for compliance skill | @@ -285,8 +288,9 @@ make status # all containers should be Up (healthy) | Variable | Required | Description | |---|---|---| -| `TELEGRAM_BOT_TOKEN` | ✅ | Telegram bot token placeholder | +| `TELEGRAM_BOT_TOKEN` | ✅ | Telegram bot token placeholder (provisioned into `hermes_data/.env` by `make bootstrap` / `add-user.sh`) | | `TELEGRAM_ALLOWED_USERS` | ✅ | Allowed Telegram ID(s), comma-separated | +| `HERMES_GATEWAY_TOKEN` | ✅ | Hermes gateway auth token (auto-generated by `add-user.sh`) | | `BROKER_KEY` | ✅ | Instance broker key (auto-generated by `add-user.sh`) | | `YANDEX_OAUTH_TOKEN` | — | Yandex OAuth for mail / Tracker | | `YANDEX_CALDAV_URL` | — | Yandex Calendar CalDAV URL | diff --git a/README.ru.md b/README.ru.md index 0331505..bc49010 100644 --- a/README.ru.md +++ b/README.ru.md @@ -249,7 +249,7 @@ make add-user NAME=user-2 \ Опционально — заполнить личные токены и настроить персонажа: ```bash nano instances/user-2/.env # Яндекс OAuth, GitLab token, etc. -nano instances/user-2/workspace/SOUL.md # стиль и характер ассистента +nano instances/user-2/workspace/SOUL.md # стиль и характер ассистента (синхронизируется в hermes_data/SOUL.md при make bootstrap / make deploy) ``` ### 6. Запустить @@ -268,9 +268,12 @@ make status # все контейнеры должны быть Up (healthy) | Переменная | Обязательно | Описание | |---|---|---| | `ANTHROPIC_API_KEY` | ✅ | API-ключ Anthropic для прямого доступа admin-инстанса; сгенерированные инстансы должны ходить через quota keys и `quota-proxy` | +| `CLIPROXY_API_KEY` | ✅ | Ключ авторизации `quota-proxy` → `cliproxyapi` (`clip-…`) | | `QUOTA_ADMIN_TOKEN` | ✅ | Bearer-токен для API управления квотами | | `QUOTA_DEFAULT_MONTHLY` | — | Лимит токенов по умолчанию (по умолчанию: `1000000`) | | `BROKER_KEY_ADMIN` | ✅ | Ключ admin в брокере | +| `BROKER_KEY_SERVICE` | ✅ | Ключ service-agent в брокере | +| `BROKER_KEY_MONITOR` | ✅ | Ключ resource-monitor в брокере | | `MONITOR_ADMIN_TOKEN` | ✅ | Bearer-токен для API resource-monitor | | `SERVICE_API_KEY` | ✅ | Bearer-токен для service-agent | | `ENRICH_PY_PATH` | — | Путь к `enrich.py` на хосте для compliance-скилла | @@ -283,8 +286,9 @@ make status # все контейнеры должны быть Up (healthy) | Переменная | Обязательно | Описание | |---|---|---| -| `TELEGRAM_BOT_TOKEN` | ✅ | Placeholder токена Telegram-бота | +| `TELEGRAM_BOT_TOKEN` | ✅ | Токен Telegram-бота (переносится в `hermes_data/.env` через `make bootstrap` / `add-user.sh`) | | `TELEGRAM_ALLOWED_USERS` | ✅ | Разрешённые Telegram ID через запятую | +| `HERMES_GATEWAY_TOKEN` | ✅ | Токен авторизации Hermes gateway (генерируется `add-user.sh`) | | `BROKER_KEY` | ✅ | Ключ брокера (генерируется `add-user.sh`) | | `YANDEX_OAUTH_TOKEN` | — | Яндекс OAuth для почты / Трекера | | `YANDEX_CALDAV_URL` | — | CalDAV URL Яндекс Календаря | diff --git a/docker-compose.yml b/docker-compose.yml index cdc8dd6..374d803 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -134,6 +134,7 @@ services: - /usr/bin/docker:/usr/bin/docker:ro # docker CLI с хоста environment: - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # admin ходит напрямую + - OPENAI_API_KEY=${OPENAI_API_KEY:-} - BROKER_URL=http://message-broker:8080 - BROKER_KEY=${BROKER_KEY_ADMIN} - QUOTA_PROXY_URL=http://quota-proxy:9090 diff --git a/instances/.env.example b/instances/.env.example index bc91c05..13bf4ad 100644 --- a/instances/.env.example +++ b/instances/.env.example @@ -12,8 +12,9 @@ TELEGRAM_ALLOWED_USERS=change-me-telegram-id # Для admin: python3 -c "import secrets; print(secrets.token_urlsafe(32))" HERMES_GATEWAY_TOKEN=change-me-gateway-token -# OpenAI API key — для транскрипции голосовых (voice.stt.provider: openai) -OPENAI_API_KEY=change-me-openai-key +# (Опционально) OpenAI API key — для транскрипции голосовых (voice.stt.provider: openai) +# По умолчанию берётся из глобального .env через make bootstrap +# OPENAI_API_KEY=sk-... # Broker — внутренняя шина (URL не менять, ключ генерируется через add-user.sh) BROKER_URL=http://message-broker:8080 diff --git a/scripts/post-merge-hook.sh b/scripts/post-merge-hook.sh index 7b488b1..af1dc80 100644 --- a/scripts/post-merge-hook.sh +++ b/scripts/post-merge-hook.sh @@ -31,6 +31,7 @@ echo "$CHANGED" | while read -r f; do resource-monitor/*) echo "resource-monitor" >> /tmp/.cf_rebuild ;; service-agent/*) echo "assistant-service" >> /tmp/.cf_rebuild ;; instances/admin/Dockerfile) echo "assistant-admin" >> /tmp/.cf_rebuild ;; + instances/Dockerfile.user) echo "__user_images__" >> /tmp/.cf_rebuild ;; docker-compose.yml) echo "__all__" >> /tmp/.cf_rebuild ;; esac case "$f" in @@ -72,6 +73,14 @@ fi # Пересборка изменившихся сервисов if [ -n "$REBUILD_LIST" ]; then for svc in $REBUILD_LIST; do + if [ "$svc" = "__user_images__" ]; then + USERS=$($COMPOSE ps --services 2>/dev/null | grep -v -E "^(assistant-admin|quota-proxy|message-broker|resource-monitor|assistant-service)$" | grep "assistant-" || true) + for usvc in $USERS; do + echo " 🔨 Пересобираю $usvc..." + $COMPOSE up -d --build "$usvc" 2>&1 | grep -E "(Building|built|Started|Recreated|error)" || true + done + continue + fi echo " 🔨 Пересобираю $svc..." $COMPOSE up -d --build "$svc" 2>&1 | grep -E "(Building|built|Started|Recreated|error)" || true done From 78ba1e9d3d266495113cd83bf31b793ca101d015 Mon Sep 17 00:00:00 2001 From: nikita42 <13642481+rekurt@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:57:45 +0300 Subject: [PATCH 5/5] security(scripts): reject symlinks when syncing SOUL.md and instance files --- scripts/migrate-instances.py | 14 +++-- scripts/provision-hermes-data.sh | 63 ++++++++++++++++++---- scripts/sync-instances.sh | 14 +++-- tests/test_symlink_protection.py | 90 ++++++++++++++++++++++++++++++++ 4 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 tests/test_symlink_protection.py diff --git a/scripts/migrate-instances.py b/scripts/migrate-instances.py index 3ffedb5..6d193b4 100644 --- a/scripts/migrate-instances.py +++ b/scripts/migrate-instances.py @@ -102,11 +102,19 @@ def run(): print(f" ❌ {mid}: {e}") if instance_updated: updated.append(instance_dir.name) - # Синхронизируем SOUL.md из workspace в hermes_data (~/.hermes/SOUL.md) + # Синхронизируем SOUL.md из workspace в hermes_data (~/.hermes/SOUL.md), отклоняя симлинки ws_soul = workspace / "SOUL.md" hd_soul = instance_dir / "hermes_data" / "SOUL.md" - if ws_soul.exists() and hd_soul.parent.exists() and not DRY_RUN: - shutil.copyfile(ws_soul, hd_soul) + if ( + not workspace.is_symlink() + and not hd_soul.parent.is_symlink() + and ws_soul.exists() + and not ws_soul.is_symlink() + and hd_soul.parent.exists() + and not hd_soul.is_symlink() + and not DRY_RUN + ): + shutil.copyfile(ws_soul, hd_soul, follow_symlinks=False) # Сбрасываем кеш сессий — иначе агент продолжит работать # со старым SOUL.md/AGENTS.md из закешированной истории sessions_dir = instance_dir / "hermes_data" / "sessions" diff --git a/scripts/provision-hermes-data.sh b/scripts/provision-hermes-data.sh index 8c56ea5..6c08601 100755 --- a/scripts/provision-hermes-data.sh +++ b/scripts/provision-hermes-data.sh @@ -13,21 +13,25 @@ TEMPLATE="$INSTANCES/_template" provision() { local NAME="$1" local DIR="$INSTANCES/$NAME" - if [ ! -d "$DIR" ]; then - echo "⚠️ $DIR не найден — пропускаем" + if [ ! -d "$DIR" ] || [ -L "$DIR" ]; then + echo "⚠️ $DIR не найден или является симлинком — пропускаем" + return 0 + fi + if [ -L "$DIR/hermes_data" ] || [ -L "$DIR/workspace" ]; then + echo "❌ [$NAME] hermes_data или workspace является симлинком — пропускаем (защита от symlink-атаки)" return 0 fi mkdir -p "$DIR/hermes_data/ssh" # Конфиг Hermes для user-инстансов (у admin свой instances/admin/config.yaml через bind-mount) - if [ "$NAME" != "admin" ] && [ ! -f "$DIR/hermes_data/config.yaml" ] && [ -f "$TEMPLATE/hermes_config.yaml.template" ]; then + if [ "$NAME" != "admin" ] && [ ! -e "$DIR/hermes_data/config.yaml" ] && [ ! -L "$DIR/hermes_data/config.yaml" ] && [ -f "$TEMPLATE/hermes_config.yaml.template" ]; then cp "$TEMPLATE/hermes_config.yaml.template" "$DIR/hermes_data/config.yaml" chmod 600 "$DIR/hermes_data/config.yaml" echo " [$NAME] hermes_data/config.yaml создан из шаблона" fi - # .env для Hermes — из instance .env (поддержка старых и новых имён переменных; || true защищает от pipefail) - if [ ! -f "$DIR/hermes_data/.env" ] && [ -f "$DIR/.env" ]; then + # .env для Hermes — из instance .env (отклоняем симлинки; || true защищает от pipefail) + if [ ! -e "$DIR/hermes_data/.env" ] && [ ! -L "$DIR/hermes_data/.env" ] && [ -f "$DIR/.env" ] && [ ! -L "$DIR/.env" ]; then local tg_token tg_users gw ak oai name_upper name_upper=$(echo "$NAME" | tr '[:lower:]' '[:upper:]') tg_token=$(grep -E '^TELEGRAM_BOT_TOKEN=' "$DIR/.env" | head -1 | cut -d= -f2- || true) @@ -65,12 +69,49 @@ provision() { echo " [$NAME] hermes_data/.env создан" fi - # Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), источником истины служит workspace/SOUL.md - if [ -f "$DIR/workspace/SOUL.md" ]; then - if [ ! -f "$DIR/hermes_data/SOUL.md" ] || ! diff -q "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" >/dev/null 2>&1; then - cp "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" - echo " [$NAME] SOUL.md синхронизирован в hermes_data/" - fi + # Персона: Hermes читает SOUL.md из ~/.hermes (=/opt/data), источником истины служит workspace/SOUL.md. + # Защита от symlink-атаки (O_NOFOLLOW): отказ при симлинках в источнике или приёмнике. + if [ -L "$DIR/workspace/SOUL.md" ] || [ -L "$DIR/hermes_data/SOUL.md" ]; then + echo " ⚠️ [$NAME] SOUL.md является симлинком — синхронизация отклонена" + elif [ -f "$DIR/workspace/SOUL.md" ]; then + python3 - "$DIR/workspace/SOUL.md" "$DIR/hermes_data/SOUL.md" "$NAME" << 'PYEOF' +import os, stat, sys + +src, dst, name = sys.argv[1], sys.argv[2], sys.argv[3] +nofollow = getattr(os, "O_NOFOLLOW", 0) +fd_in = os.open(src, os.O_RDONLY | nofollow) +try: + st = os.fstat(fd_in) + if not stat.S_ISREG(st.st_mode): + sys.exit(0) + with os.fdopen(fd_in, "rb") as f_in: + data = f_in.read() + fd_in = -1 +finally: + if fd_in >= 0: + os.close(fd_in) + +existing = None +if os.path.lexists(dst): + fd_old = os.open(dst, os.O_RDONLY | nofollow) + try: + with os.fdopen(fd_old, "rb") as f_old: + existing = f_old.read() + fd_old = -1 + finally: + if fd_old >= 0: + os.close(fd_old) + +if existing != data: + tmp = dst + ".tmp" + if os.path.lexists(tmp): + os.unlink(tmp) + fd_out = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | nofollow, 0o600) + with os.fdopen(fd_out, "wb") as f_out: + f_out.write(data) + os.replace(tmp, dst) + print(f" [{name}] SOUL.md синхронизирован в hermes_data/") +PYEOF fi # Права: HERMES_UID/GID=1000 — иначе s6-процесс не сможет писать в свой home diff --git a/scripts/sync-instances.sh b/scripts/sync-instances.sh index 66ce2be..944db05 100755 --- a/scripts/sync-instances.sh +++ b/scripts/sync-instances.sh @@ -25,12 +25,12 @@ for INST_DIR in "$INSTANCES"/*/; do WS="$INSTANCES/$NAME/workspace" HD="$INSTANCES/$NAME/hermes_data" - [ -d "$WS" ] || continue + [ -d "$WS" ] && [ ! -L "$WS" ] || continue - # SOUL.md: источником истины служит workspace/SOUL.md, Hermes читает hermes_data/SOUL.md - if [ -f "$WS/SOUL.md" ] && [ -d "$HD" ]; then + # SOUL.md: источником истины служит workspace/SOUL.md, Hermes читает hermes_data/SOUL.md (отклоняем симлинки) + if [ -f "$WS/SOUL.md" ] && [ ! -L "$WS/SOUL.md" ] && [ -d "$HD" ] && [ ! -L "$HD" ] && [ ! -L "$HD/SOUL.md" ]; then if [ ! -f "$HD/SOUL.md" ] || ! diff -q "$WS/SOUL.md" "$HD/SOUL.md" > /dev/null 2>&1; then - cp "$WS/SOUL.md" "$HD/SOUL.md" + cp -P "$WS/SOUL.md" "$HD/SOUL.md" CHANGES+=("[$NAME] SOUL.md синхронизирован в hermes_data/") fi fi @@ -38,7 +38,11 @@ for INST_DIR in "$INSTANCES"/*/; do [ "$NAME" = "$ADMIN_INSTANCE" ] && continue # у Admin свой HEARTBEAT HB="$WS/HEARTBEAT.md" - if [ ! -f "$HB" ]; then + if [ -L "$HB" ]; then + rm -f "$HB" + cp "$HEARTBEAT_REF" "$HB" + CHANGES+=("[$NAME] HEARTBEAT.md был симлинком — пересоздан из эталона") + elif [ ! -f "$HB" ]; then cp "$HEARTBEAT_REF" "$HB" CHANGES+=("[$NAME] HEARTBEAT.md отсутствовал — восстановлен") elif ! diff -q "$HB" "$HEARTBEAT_REF" > /dev/null 2>&1; then diff --git a/tests/test_symlink_protection.py b/tests/test_symlink_protection.py new file mode 100644 index 0000000..39f1441 --- /dev/null +++ b/tests/test_symlink_protection.py @@ -0,0 +1,90 @@ +import os +import pathlib +import subprocess +import tempfile +import unittest + + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +PROVISION_SCRIPT = REPO_ROOT / "scripts" / "provision-hermes-data.sh" + + +class TestSymlinkProtection(unittest.TestCase): + """Ensure host provisioning scripts refuse to follow symlinks inside user-writable directories.""" + + def test_provision_rejects_soul_md_symlink_to_secret(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + (root / "scripts").mkdir() + (root / "instances" / "_template").mkdir(parents=True) + (root / "instances" / "_template" / "hermes_config.yaml.template").write_text("model:\n provider: anthropic\n") + + # Copy provision script into temp root + script_copy = root / "scripts" / "provision-hermes-data.sh" + script_copy.write_text(PROVISION_SCRIPT.read_text()) + script_copy.chmod(0o755) + + secret_env = root / ".env" + secret_env.write_text("ANTHROPIC_API_KEY=test-root-key\nQUOTA_KEY_USER1=quota-user1-abc\n") + + user_dir = root / "instances" / "user1" + (user_dir / "workspace").mkdir(parents=True) + (user_dir / "hermes_data").mkdir(parents=True) + (user_dir / ".env").write_text("TELEGRAM_BOT_TOKEN=123:abc\nTELEGRAM_ALLOWED_USERS=111\n") + + # Attacker replaces workspace/SOUL.md with a symlink to root .env + soul_link = user_dir / "workspace" / "SOUL.md" + os.symlink(secret_env, soul_link) + + res = subprocess.run( + ["bash", str(script_copy), "user1"], + cwd=str(root), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(res.returncode, 0, msg=res.stderr) + target_soul = user_dir / "hermes_data" / "SOUL.md" + self.assertFalse( + target_soul.exists(), + "hermes_data/SOUL.md must NOT be created when workspace/SOUL.md is a symlink", + ) + + def test_provision_copies_regular_soul_md(self): + with tempfile.TemporaryDirectory() as tmpdir: + root = pathlib.Path(tmpdir) + (root / "scripts").mkdir() + (root / "instances" / "_template").mkdir(parents=True) + (root / "instances" / "_template" / "hermes_config.yaml.template").write_text("model:\n provider: anthropic\n") + + script_copy = root / "scripts" / "provision-hermes-data.sh" + script_copy.write_text(PROVISION_SCRIPT.read_text()) + script_copy.chmod(0o755) + + (root / ".env").write_text("ANTHROPIC_API_KEY=test-root-key\nQUOTA_KEY_USER1=quota-user1-xyz\n") + + user_dir = root / "instances" / "user1" + (user_dir / "workspace").mkdir(parents=True) + (user_dir / "hermes_data").mkdir(parents=True) + (user_dir / ".env").write_text("TELEGRAM_BOT_TOKEN=123:abc\nTELEGRAM_ALLOWED_USERS=111\n") + (user_dir / "workspace" / "SOUL.md").write_text("# Regular Soul Persona\n") + + res = subprocess.run( + ["bash", str(script_copy), "user1"], + cwd=str(root), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(res.returncode, 0, msg=res.stderr) + target_soul = user_dir / "hermes_data" / "SOUL.md" + self.assertTrue(target_soul.exists()) + self.assertEqual(target_soul.read_text(), "# Regular Soul Persona\n") + # Verify strict quota key separation + env_text = (user_dir / "hermes_data" / ".env").read_text() + self.assertIn("ANTHROPIC_API_KEY=quota-user1-xyz", env_text) + self.assertNotIn("test-root-key", env_text) + + +if __name__ == "__main__": + unittest.main()