# Фаза 4 — исполнитель на Mac + `~/agents` + секция «Agents» в Eagle Dashboard > План + статус. Старт 2026-09-26. > Часть [[personal/projects/balda/balda-mm-delegation-plan]] (Фазы 4-5). > Предыдущее: [[personal/projects/balda/balda-kraken-mm-deploy]] (Фаза 3 ✅ — бот отвечает Alex). --- ## 0. Статус шагов | # | Шаг | Статус | |---|---|---| | 1 | `~/agents` + `~/cli-auth` + контейнер | ✅ собрано, запущено | | 2 | Мост FastAPI :8790 | ✅ работает под launchd, туннель `agents-bridge.qentra.top` заведён Alex'ом | | 3 | Сеть Kraken → Mac | ✅ **Cloudflare Tunnel** (постоянно); LAN остаётся для отладки | | 4 | Секция «Agents» в Dashboard | ✅ активирована и проверена | | 5 | Связка Балда → мост | ✅ конфиг переведён на `type: http`, образ 0.2.0, `tools/list` из docker-сети | | 6 | Obsidian MCP для Балды | ✅ контейнер `obsidian-mcp` на Kraken + `type: sse` в конфиге Балды | | 7 | Приёмка от аккаунта Alex | 🔴 блокер: авторизация CLI | --- ## 1. Решения | # | Вопрос | Решение | |---|---|---| | 1 | Папка для проектов исполнителя | `~/agents` | | 2 | Подтверждения | Full access → `permissions.mode: allow_all` | | 3 | Управление линками в `~/agents` | Секция «Agents» в Eagle Dashboard | | 4 | Auth CLI (claude/codex) | на хосте, `~/cli-auth`, **вне** `~/agents`; логинит **Alex вручную** | | 5 | Сеть Kraken → Mac | 🟢 **Cloudflare Tunnel `agents-bridge.qentra.top`** — постоянный путь (Alex завёл маршрут); LAN `192.168.1.57:8790` — только для отладки | | 6 | Провайдер исполнителя | `claude` (дефолт в `.env`); `codex` собирается в том же образе, переключается переменной | | 7 | Тип MCP-транспорта в конфиге Балды | `http` (валидатор: `stdio \| http \| sse`); Obsidian — `sse` | | 8 | Туннель для Obsidian MCP | ❌ **не нужен** — контейнер стоя́т на Kraken рядом с Балдой, ходят по docker-сети | --- ## 2. Ограничения macOS Docker (проверено тестами) | Ограничение | Итог | |---|---| | Симлинк на внешний путь | битый в контейнере — цель резолвится от корня контейнера | | Live `mount --bind` внутри VM | контейнер не видит (virtiofs строит маппинг на старте) | | Сетевая шара SMB/NFS | в контейнер не протянуть (тот же virtiofs) | | Hardlink на каталог | физически невозможен (ядро запрещает) | | Hardlink через границу ФС | `EXDEV` | **Следствие:** внешние проекты подключаются **только bind-mount'ом через `-v`** + `docker-compose up -d` (2-3 сек). Hardlink — только для файлов внутри одного тома. Детали механизма: [[personal/tech/macos-docker-virtiofs-mount-limits]]. --- ## 3. Архитектура (собрано) ``` Mattermost (Alex) │ «почини баг X в project-y» ▼ Балда-оркестратор (Kraken, DeepSeek, allow_all) │ читает Obsidian, формирует ТЗ, /goalkeeper → work → validate ▼ MCP-сервер balda-executor-mcp (Kraken, контейнер в mattermost_mm-net) :8890 │ инструменты executor_run / executor_status / executor_health │ HTTP + X-Bridge-Secret ▼ LAN: http://192.168.1.57:8790 ← Kraken и Mac в одной сети, туннель не нужен ▼ Мост (Mac, FastAPI :8790) — под launchd com.agents.bridge │ idempotency key, таймаут 900 с, лимит 1 задача, shared secret ▼ Контейнер-исполнитель balda-executor (Mac, Docker) │ -v ~/agents:/agents:rw │ -v ~/Developer/:/agents/:rw ← внешние проекты (override) │ -v ~/cli-auth/claude:/root/.claude:rw ← auth на хосте │ -v ~/cli-auth/codex:/root/.codex:rw ← auth на хосте │ Node 24 + claude 2.1.283 + codex 0.157.1 + git ▼ diff + тесты + коммит → Балда → Mattermost ``` Детали MCP-сервера: [[personal/tech/balda-executor-mcp]]. ### Auth CLI на хосте | Данные | Хост | В контейнере | Права | |---|---|---|---| | Claude auth | `~/cli-auth/claude` | `/root/.claude` | 0700 | | Codex auth | `~/cli-auth/codex` | `/root/.codex` | 0700 | Пересборка образа и обновление CLI auth не сбрасывают. Токены вне `/agents` → агент их не видит. --- ## 4. Что собрано на диске | Путь | Что | |---|---| | `~/agents/` | рабочая область исполнителя | | `~/cli-auth/{claude,codex}` | 0700, монтируются в контейнер | | `~/Docker/balda-executor/Dockerfile` | Node 24 + claude + codex + git + tini + CA | | `~/Docker/balda-executor/docker-compose.yml` | 4 тома, сеть `executor-net`, build-секция | | `~/Docker/balda-executor/.env` | `EXECUTOR_PROVIDER=claude`, 0600 | | `~/Docker/balda-executor/agents-mounts.yaml` | манифест внешних проектов | | `~/Docker/balda-executor/tools/render-mounts.py` | манифест → `compose.override.yaml` | | `~/Developer/agents-bridge/` | мост: `app.py`, `store.py`, `executor.py`, `config.py` | | `~/Library/LaunchAgents/com.agents.bridge.plist` | автозапуск + KeepAlive, `--host 0.0.0.0` | | `~/Developer/eagle-dash/agents.py` | роутер секции «Agents» | | `~/Developer/balda-executor-mcp/` | MCP-сервер: `server.py`, `Dockerfile`, `docker-compose.yml` | | `/home/kraken/mcp/docker-compose.yml` (Kraken) | запуск контейнера MCP | **Известные питфоллы (пройдены):** - `docker compose` на Mac **отсутствует** — только `docker-compose` (v5.1.3); **на Kraken наоборот**: `docker compose`, а `docker-compose` → `command not found`; - `compose.override.yaml` обязателен при старте, иначе `up` падает на монтировании; - образ исполнителя: `node:24-bookworm` вместо `scratch` (CA-сертификаты — иначе TLS-фейл); - `psutil` не установлен в venv дашборда → supervisor не может трекать мост по PID; health для моста — `type: http`; - `--host` берётся из `ProgramArguments` plist, **`.env` его не перебивает**; смена аргументов требует `bootout`+`bootstrap`, не `kickstart`; - образ Балды из `scratch`: **нет shell**, `docker exec … sh` не работает; `docker.sock` в контейнер Балды **не смонтирован** → `docker run` изнутри невозможен; - на Kraken в sshd `AllowTcpForwarding no` → обратный SSH-туннель поставить нельзя. --- ## 5. Секция «Agents» в Eagle Dashboard Правки: `main.py` (include_router) · `agents.py` (новый роутер) · `templates/index.html` (вкладка `?tab=agents`) · `services.yaml` (сервис `agents_bridge`). | Функция | Что делает | |---|---| | Список | содержимое `~/agents`: имя, размер, mtime, статус линка | | Создать | `mkdir ~/agents/` | | Подключить внешний | запись в `agents-mounts.yaml` → `render-mounts.py` → `up -d` | | Hardlink | `ln -F` для файлов, проверка `st_dev` | | Отключить | убрать из манифеста + `up -d`; **файлы на диске целы** | | Статус моста | `/api/agents/bridge` — provider, container, active, limit, pid, secret | | Терминал | ссылка на вкладку `files` | **API:** `GET/POST /api/agents` · `POST|DELETE /api/agents/{name}/mount` · `POST /api/agents/{name}/hardlink` · `GET /api/agents/{name}/usage` · `GET /api/agents/mounts` · `POST /api/agents/mounts/render` · `GET /api/agents/bridge`. **Не делает:** не удаляет файлы, не убивает процессы, не создаёт symlink на внешние цели. ### Приёмка секции (2026-09-26) — 13 проверок, все прошли | Проверка | Результат | |---|---| | создание агента | 201 ✅ | | повторное создание | 409 ✅ | | подключение внешнего проекта | 200 + контейнер Recreated ✅ | | проект виден внутри контейнера | да, `ls /agents/testproj` → файлы ✅ | | отвязка | 200; внутри контейнера пусто, на хосте файлы целы ✅ | | hardlink на каталог | 400 (ядро запрещает) ✅ | | hardlink на файл в том же томе | 200, `links=2`, одинаковый inode ✅ | | слишком широкий `host_path` | 400 ✅ | | размер каталога | OK ✅ | | `?tab=agents` рендерится | 200, 75 КБ ✅ | | JS-целостность шаблона | скобки парны, 8 методов на месте ✅ | | Alpine в логе дашборда | ошибок нет ✅ | --- ## 6. Мост `agents-bridge` `~/Developer/agents-bridge/`. Запуск под launchd: `com.agents.bridge`, лог `~/Library/Logs/agents-bridge.err.log`. | Эндпоинт | Назначение | |---|---| | `GET /healthz` | живость моста + контейнера | | `POST /task` | поставить задачу `{prompt, provider?}` | | `GET /task/{id}` | статус + stdout/stderr/exit_code | | `GET /tasks` | последние 20 задач | Безопасность: заголовок `X-Bridge-Secret` (иначе 401), `Idempotency-Key` (повтор возвращает тот же `task_id`), лимит 1 одновременная задача (иначе 429), таймаут 900 с, обрезка вывода 200 КБ. ### Приёмка моста (2026-09-26) — 6 проверок, все прошли | Проверка | Результат | |---|---| | без секрета / неверный секрет | 401 / 401 ✅ | | два POST с одним `Idempotency-Key` | одинаковый `task_id` ✅ | | задача дошла до CLI в контейнере | да ✅ | | ответ CLI вернулся (`Not logged in · Please run /login`) | да — это ответ Claude Code, конвейер рабочий ✅ | | состояние в SQLite (WAL) | ✅ | | `/healthz` видит контейнер | `status: ok` ✅ | **Блокер приёмки:** CLI внутри контейнера не авторизован. Логинит **Alex вручную**: `claude` / `codex` внутри контейнера, credentials кладутся в `~/cli-auth/{claude,codex}`. --- ## 7. Сеть Kraken → Mac — 🟢 Cloudflare Tunnel (постоянно), LAN — отладка ### Итог: постоянный путь — туннель, не IP 🔴 **Alex потребовал убрать LAN-IP из постоянного конфига** — домашний IP не является стабильным адресом: сменится адрес Mac или уедет роутер, и MCP отвалится молча. Туннель даёт стабильное имя и не привязан к топологии сети. ```env BRIDGE_URL=https://agents-bridge.qentra.top ``` Маршрут `agents-bridge.qentra.top` → `http://localhost:8790` заведён Alex'ом в Cloudflare Zero Trust. Проверено из docker-сети Kraken: | Проверка | Результат | |---|---| | `curl https://agents-bridge.qentra.top/healthz` из `mattermost` | HTTP 200, `{"status":"ok","container":"balda-executor",…}` ✅ | | заголовки ответа | `server: cloudflare`, `cf-ray: …-FRU`, `HTTP/2 200` ✅ | | `ping` Kraken → `192.168.1.57` (LAN, резерв) | 2/2, RTT 2.4 мс ✅ | ⚠️ **На момент записи Access Application на `agents-bridge` не задан** — туннель отвечает 200 без авторизации. Пока защита — `X-Bridge-Secret` и непредсказуемое имя поддомена. **Завести Access Application** (или подтвердить, что она есть), иначе мост фактически открыт в интернет. ### Что НЕ сработало и почему (чтобы не повторять) | Путь | Почему отпал | |---|---| | Обратный SSH-туннель (`ssh -R`) | 🔴 **На Kraken в sshd стоит `AllowTcpForwarding no`.** Симптом: `Error: remote port forwarding failed for listen port 18790`, в verbose — `debug1: Remote: Server has disabled port forwarding.` Не диагностировать как «порт занят»: порт свободен, форвард выключен администратором | | LAN как постоянный адрес | Alex против: IP не стабилен. Годится только для отладки | 📌 Если понадобится туннель в будущем — сначала проверить `AllowTcpForwarding` на Kraken, потом уже ставить форвард. Cloudflare Tunnel этот запрет обходит (исходящее соединение, не SSH-форвард). ### Мост теперь слушает всю сеть — это осознанное изменение | Было | Стало | Почему | |---|---|---| | `BRIDGE_HOST=127.0.0.1` | `BRIDGE_HOST=0.0.0.0` | иначе Kraken не достучится | ⚠️ **Порт 8790 теперь открыт в локальной сети.** Защита — `X-Bridge-Secret` (обязателен, иначе `POST /task` отдаёт 503). При уходе Mac в недоверенную сеть — вернуть `127.0.0.1` и поднимать туннель. 🔴 **Питфолл: `.env` не перебивает plist.** Я поменял `BRIDGE_HOST` в `.env`, но мост продолжал слушать `127.0.0.1` — потому что `--host` **жёстко задан в `ProgramArguments`** plist. Симптом: `lsof` показывает `127.0.0.1:8790` после «перезапуска с новым конфигом». Исправлять надо **в plist**, а не в `.env`. ```bash # применить: правка plist → перезагрузка сервиса (не kickstart!) launchctl bootout gui/$(id -u)/com.agents.bridge launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agents.bridge.plist # проверка: должно быть *:8790, а не 127.0.0.1:8790 lsof -nP -iTCP:8790 -sTCP:LISTEN ``` `kickstart` перечитывает только программу, но **не** изменённый `ProgramArguments` — для смены аргументов нужен `bootout` + `bootstrap`. --- ## 8. Риски | Риск | Оценка | Митигация | |---|---|---| | Мост опубликован в интернет | высокий | Access Application + shared secret | | Полный доступ агента к `~/agents` | средний | бэкапы; `git rm`-семантика вместо `rm` | | Бан подписки за «unusual traffic» | средний | 1 параллельная задача, официальный бинарь, без прокси токена | | Hardlink через ФС → `EXDEV` | высокий | проверка `st_dev` в API | | Mac спит → туннель и мост недоступны | средний | задачи в durable-очереди Балды, идемпотентность | --- ## 9. Открыто 1. ✅ **Конфиг Балды переведён на HTTP-MCP** — `type: http` → `http://balda-executor-mcp:8890/mcp`. Плюс образ на Kraken обновлён до `0.2.0` (в `0.1.0` нет `uvicorn` → рестарт-петля). См. [[personal/tech/balda-executor-mcp]] §10e-10f. 2. ✅ **Obsidian прописан в конфиг Балды** — контейнер `obsidian-mcp` на Kraken поднят, `runtime.mcp_servers.obsidian` добавлен. ⚠️ Тип — **`sse`**, не `http`: ```yaml runtime: mcp_servers: executor-mcp: type: http url: "http://balda-executor-mcp:8890/mcp" obsidian: type: sse url: "http://obsidian-mcp:3101/sse" balda: mcp_servers: - executor-mcp - obsidian ``` См. §11 ниже. 3. 🟡 Проверить, что инструменты видны в сессии Балды — **постом от админа** в Mattermost. ⚠️ Пост **от имени самого бота** Балда игнорирует (правильно); для теста нужен пост от админского токена (`mattermost mmctl --local token generate admin …`). 4. 🔴 Логин CLI (Alex вручную) — без него сквозная приёмка невозможна. 5. 🔴 **Завести Access Application** на `agents-bridge.qentra.top` — сейчас туннель отвечает 200 без авторизации (проверено). Защита — только `X-Bridge-Secret`. 6. ⚠️ **`~/Developer/balda-executor-mcp/docker-compose.yml` в репо → `0.2.0`**; на Kraken compose лежит в `/home/kraken/balda-executor-mcp/docker-compose.yml` (был `/home/kraken/mcp/` — путь изменился, старый compose мёртв). ## 9a. 🔴 Obsidian на Kraken — существовал ДО этой сессии Важное исправление ошибочного вывода. Я дважды сказал Alex, что Obsidian MCP на Kraken нет / есть только на Mac. **Оба утверждения неверны.** **Почему ошибся:** искал через `docker ps --filter name=obsidian` — контейнера нет. Но на Kraken Obsidian MCP запущен **не контейнером**, а как stdio-процесс внутри агента Hermes: ``` mcpvault /vault (PID 1168741, UID 10000) └─ родитель PID 5039 = hermes gateway (контейнер hermes-kraken) ``` Конфиг — `/opt/data/config.yaml` **внутри** `hermes-kraken`. Волт на Kraken реальный: `/home/kraken/obsidian` → `/srv/…-49e8f586…/obsidian` (с `personal/`, `family/`, `.git/`). 📌 **Правило поиска: `docker ps` не является доказательством отсутствия сервиса.** Сначала `ps aux | grep `, потом порты (`ss -tlnp`), и только потом контейнеры. ## 10. Шаг 5 — связка Балда → мост (обновлено 2026-09-26) | Компонент | Где | Статус | |---|---|---| | MCP-сервер `balda-executor-mcp` | `~/Developer/balda-executor-mcp/` | ✅ код + образ `0.2.0` | | Образ на Kraken | `balda-executor-mcp:0.2.0` (0.1.0 оставлен для отката) | ✅ загружен, хеш сверен | | Контейнер MCP | Kraken, `/home/kraken/balda-executor-mcp/docker-compose.yml` | ✅ Up, `tools/list` = 3 инструмента | | Конфиг Балды | `runtime.mcp_servers.{executor-mcp: http, obsidian: sse}` | ✅ залит | | `.env` Балды | `BRIDGE_URL=https://agents-bridge.qentra.top` + `BRIDGE_SECRET` | ✅ туннель, не IP | Бэкапы на Kraken: `config.yaml.bak-mcp-20260926-194313`, `config.yaml.bak-obsidian-20260926-214849`, `.env.bak-mcp-*`, `.env.bak-url-*`. Проверено живьём: `/healthz` = `{"status":"ok",…}`, `tools/list` из docker-сети отдаёт `executor_run` / `executor_status` / `executor_health`, туннель до моста отвечает 200 через Cloudflare. 📌 **Ключевой вывод по архитектуре:** MCP-сервер обязан быть **отдельным контейнером с HTTP-транспортом**. Вариант «stdio + `docker run` изнутри Балды» нерабочий: в образе Балды нет shell и не смонтирован `docker.sock`. ## 11. Obsidian MCP на Kraken — контейнер (сделан в этой сессии) Балде нужен Obsidian **не по туннелю**: контейнер стоит рядом, в той же docker-сети. Туннель к Obsidian Alex заводить не надо — это была моя ошибка в предложении. | Что | Значение | |---|---| | Проект | `~/Developer/obsidian-mcp-kraken/` (Dockerfile) | | Образ | `obsidian-mcp-image:0.1.0` (node + `mcp-proxy` + `mcpvault`) | | Контейнер на Kraken | `obsidian-mcp`, сеть `mattermost_mm-net` | | Внутренний порт | `3101` (`--port 3101 -- mcpvault /vault`), **наружу не публикуется** | | Том | `/srv/dev-disk-by-uuid-49e8f586-…/obsidian:/vault:ro` (только чтение) | | Endpoint для Балды | `http://obsidian-mcp:3101/sse`, `type: sse` | ⚠️ **Порт именно 3101, не 8080.** `mcp-proxy` слушает порт из `--port`; проба на 8080 даёт `Connection refused` при живом контейнере. Проверять так: ```bash # из контейнера в mattermost_mm-net (там есть curl) docker exec mattermost sh -c 'curl -sS -m 8 -H "Accept: text/event-stream" http://obsidian-mcp:3101/sse' # ожидаем: event: endpoint / data: /messages?sessionId=… ``` 📌 **`mcpvault` — только stdio.** HTTP-режима у него нет, поэтому и нужен обёртка `mcp-proxy` (`ENTRYPOINT mcp-proxy`, `CMD --port 3101 -- mcpvault /vault`). Проверено на живом контейнере: `mcp-proxy --port 3101 -- mcpvault /vault`. 📌 У `mcp-proxy` **нет флага `--host`** — в отличие от Python-моста. Передавать только `--port` + `--`. Лишний `--host` роняет контейнер. ## Связанные заметки - [[personal/projects/balda/balda-mm-delegation-plan]] — общий план (Фазы 4-5) - [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3 (✅) - [[personal/plans/balda-mm-pr-plan]] — Фаза 2 (PR #8) - [[personal/tech/balda-executor-mcp]] — MCP-сервер делегирования (шаг 5) - [[personal/tech/agents-bridge]] — мост :8790 (шаг 2) - [[personal/tech/eagle-dashboard-agents-tab]] — секция «Agents» в дашборде (шаг 4) - [[personal/tech/macos-docker-virtiofs-mount-limits]] — ограничения macOS Docker - [[personal/tech/balda-go-fx-pitfalls]] — fx-питфоллы, дельта-апплаер - [[family/how-to/kraken-portainer-access]] — cloudflared, routes - скилл `eagle-dashboard` — архитектура дашборда