18 KiB
Executable File
[!WARNING] Историческая схема Balda Мост Mac ↔ Kraken снят с эксплуатации 2026-09-29. Команды, контейнеры и статусы ниже относятся к тому периоду. Текущий запуск: personal/projects/balda/setup. Исполнение и границы приёмки: personal/projects/balda/execution-broker.
agents-bridge — мост Mac ↔ Kraken (FastAPI :8790)
Создан 2026-09-26. Компонент Фазы 4 personal/plans/balda-mm-phase4-agents (шаг 2 ✅). Назначение: принять задачу от оркестратора Балды (Kraken) по HTTP, запустить CLI-исполнитель в контейнере
balda-executor, вернуть вывод. 🔴 2026-09-29: провайдер меняется сclaude(подписка) наcallee(DeepSeek) — см. §Провайдеры и personal/plans/balda-callee-prism-plan.
Расположение
| Что | Путь |
|---|---|
| Проект | ~/Developer/agents-bridge/ |
| Python | ~/Developer/eagle-dash/.venv/bin/python (3.14, fastapi 0.115.0, uvicorn 0.30.6) |
| Конфиг + секрет | ~/Developer/agents-bridge/.env (0600, не коммитить) |
| Шаблон | ~/Developer/agents-bridge/.env.example |
| Состояние | ~/Developer/agents-bridge/state.sqlite3 (WAL) |
| Лог (ручной запуск) | /tmp/agents-bridge.log |
| Контейнер-исполнитель | balda-executor (~/Docker/balda-executor/) |
Своего venv у проекта нет — используется venv дашборда (там уже fastapi/uvicorn/httpx).
Файлы
| Файл | Роль |
|---|---|
app.py |
роуты, проверка секрета, лимит параллельности, ThreadPoolExecutor |
store.py |
SQLite: таблица tasks, UNIQUE-индекс по idempotency_key, reap_stale() |
executor.py |
docker exec в контейнер; провайдеры claude/codex; таймаут; обрезка вывода |
config.py |
чтение .env встроенным парсером + Settings (frozen dataclass) |
requirements.txt |
fastapi==0.115.0, uvicorn==0.30.6, httpx, python-dotenv |
API
GET /healthz → статус моста и контейнера (без секрета)
POST /task → 202 | 200 (идемпотентный повтор) | 401 | 429 | 400
GET /task/{task_id} → статус + stdout/stderr/exit_code
GET /tasks?limit=N → последние задачи (для дашборда/отладки)
Авторизация: заголовок X-Bridge-Secret на всех /task*. Если BRIDGE_SECRET не
настроен — POST /task возвращает 503, мост не принимает задачи.
Идемпотентность: заголовок Idempotency-Key. Повтор возвращает тот же task_id
с HTTP 200 (первый ответ — 202). Гарантия на уровне БД: UNIQUE-индекс + IntegrityError
как обработка гонки. CLI запускается ровно один раз на задачу.
Лимит параллельности: MAX_CONCURRENT_TASKS (по умолчанию 1). При исчерпании —
429. Больше 2 задач под одним подписочным аккаунтом — риск бана за «unusual traffic».
Провайдеры
executor.PROVIDERS — argv для headless-запуска:
| Провайдер | argv | Статус |
|---|---|---|
claude |
claude -p --output-format text <prompt> |
⛔ отменяется — требует ручного логина в контейнере |
codex |
codex exec --skip-git-repo-check <prompt> |
⛔ по той же причине |
callee |
callee agent run prism/lifecycle --message <prompt> |
🔜 целевой (план personal/plans/balda-callee-prism-plan, фаза C3) |
Дефолт — EXECUTOR_PROVIDER=claude → меняется на callee. Команда собирается
списком аргументов (без shell=True), WORKDIR=/agents внутри контейнера — наружу не выйти.
⚠️ При смене argv промпт обязан остаться ОДНИМ последним элементом списка —
["docker","exec","-w","/agents","@container", *base, prompt]. Вставка аргументов после
prompt рвёт передачу.
Переменные окружения
| Ключ | Дефолт | Смысл |
|---|---|---|
BRIDGE_HOST |
0.0.0.0 |
слушать всю LAN (Kraken ходит по LAN). Было 127.0.0.1 |
BRIDGE_PORT |
8790 |
порт |
BRIDGE_SECRET |
— | обязателен, 64 симв. secrets.token_urlsafe(48) |
EXECUTOR_CONTAINER |
balda-executor |
имя контейнера |
EXECUTOR_PROVIDER |
claude |
claude или codex |
MAX_CONCURRENT_TASKS |
1 |
одновременно выполняемых |
TASK_TIMEOUT_SECONDS |
900 |
таймаут задачи, процесс убивается |
MAX_OUTPUT_BYTES |
200000 |
вывод обрезается |
DB_PATH |
state.sqlite3 |
SQLite |
Запуск
Под launchd (с 2026-09-26) — плейт com.agents.bridge:
| Параметр | Значение |
|---|---|
| Label | com.agents.bridge |
| Plist | ~/Library/LaunchAgents/com.agents.bridge.plist |
| Program | ~/Developer/eagle-dash/.venv/bin/python -m uvicorn app:app --host 0.0.0.0 --port 8790 |
| WorkingDirectory | /Users/admin/Developer/agents-bridge |
RunAtLoad / KeepAlive |
true / true |
| Логи | ~/Library/Logs/agents-bridge.log (+ .err.log) |
PATH в EnvironmentVariables |
/opt/homebrew/bin:... — launchd не читает shell-PATH |
# установка/переустановка
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.agents.bridge.plist
# перезапуск
launchctl kickstart -k gui/$(id -u)/com.agents.bridge
# статус (pid, last exit)
launchctl list | grep agents.bridge
# проверка
curl -s http://127.0.0.1:8790/healthz
Проверено 2026-09-26: bootstrap → exit 0, в launchctl list строка 3063 0 com.agents.bridge,
/healthz = status: ok. ⚠️ Процедура убийства pid для проверки KeepAlive не выполнялась —
команда требует kill -9 и была отклонена пользователем, не повторять без явной команды.
Ручной запуск (если launchd отключён) — тем же uvicorn, но это аварийный путь.
⚠️ На macOS docker compose не работает — только docker-compose (см. план, шаг 1).
Питфоллы
1. Not logged in — это ответ CLI, а не ошибка моста
Сквозная проба до логина CLI вернула:
status=failed exit_code=1 stdout='Not logged in · Please run /login'
Это признак рабочего конвейера: docker поднял процесс, claude ответил. Если видишь
эту строку — auth у исполнителя, а не мост. Логин делает Alex сам; копирование
auth-файлов из ~/.claude/~/.codex отклонено, не предлагать.
2. Редакция секретов Hermes искажает команды создания .env
python -c с f-строкой BRIDGE_SECRET={s} печатает в лог обрезанный фрагмент
(E...cret written) — редакция Hermes, файл на диске цел. Не «чинить».
Проверять .env только так:
/usr/bin/awk -F= '{ if ($1 ~ /SECRET|TOKEN|KEY|PASSWORD/) print $1"=<redacted len "length($2)">"; else print }' .env
read_file на .env отказывает по определению (secret-bearing file) — это норма.
3. Зависшие задачи после рестарта
store.reap_stale() на старте помечает running-задачи старше TASK_TIMEOUT_SECONDS
как timeout с причиной bridge restarted while task was running. Иначе они навсегда
висят активными и блокируют слот лимита.
4. Мост не переживает перезагрузку — ✅ ЗАКРЫТО
Решено 2026-09-26: плейт com.agents.bridge (RunAtLoad + KeepAlive), поднят через
launchctl bootstrap. Шаг 3 (туннель) больше не блокирован.
⚠️ Остаётся непроверенным поведение KeepAlive при падении процесса: тест требовал
kill -9 по pid моста и был отклонён. Проверить можно launchctl kickstart -k
(штатный путь) либо перезагрузкой Mac.
5. launchd не видит docker — PATH задавать в плейте
ProgramArguments использует абсолютный путь к python, но executor.py вызывает
docker и docker-compose из PATH. При запуске под launchd PATH = /usr/bin:/bin,
поэтому docker (/opt/homebrew/bin/docker) не находится. В плейт добавлен блок
EnvironmentVariables.PATH с /opt/homebrew/bin первым. Симптом без него:
docker не найден в PATH процесса моста в stderr задачи.
6. Мост слушает 0.0.0.0 — изменение 2026-09-26
Было: BRIDGE_HOST=127.0.0.1 («наружу — через туннель»).
Стало: 0.0.0.0 — иначе Kraken не достучится по LAN.
Причина: Kraken и Mac в одной сети, ping 2.4 мс. Туннель не нужен (см.
personal/plans/balda-mm-phase4-agents §7). Kraken обращается к
http://192.168.1.57:8790.
⚠️ Порт 8790 теперь открыт в LAN. Защита — X-Bridge-Secret (без него POST /task → 503).
Уходя в недоверенную сеть — вернуть 127.0.0.1 и поднимать туннель.
🔴 --host задаётся в plist, .env его НЕ перебивает. Симптом: поменял .env,
перезапустил — lsof всё равно показывает 127.0.0.1:8790. Правка — в
ProgramArguments plist. И применять через bootout + bootstrap (не kickstart).
7. Обратный SSH-туннель на Kraken невозможен
ssh -R падает: Error: remote port forwarding failed for listen port 18790,
в verbose — debug1: Remote: Server has disabled port forwarding.
Причина: на Kraken в sshd AllowTcpForwarding no. Не путать с «порт занят» —
порт свободен, форвард выключен администратором. Обход — Cloudflare Tunnel
(исходящее соединение, не SSH-форвард).
8. 🔴 GET /task/{id} и /tasks требуют секрет; /healthz — нет
Проверено 2026-09-28. Асимметрия: /healthz открыт (это осознанно — по нему проверяют
и туннель, и мониторинг), а чтение задач тоже закрыто:
curl -s http://127.0.0.1:8790/tasks # → {"detail":"unauthorized"}
curl -s -H "X-Bridge-Secret: $SECRET" .../tasks # → JSON со списком
Симптом ошибки: jq падает Cannot index object with object — потому что в ответе
объект {"detail":"unauthorized"}, а не массив. Не jq сломан — просто нет
заголовка. Всегда проверять head -c 200 перед разбором.
📌 Полезно: ответ GET /task/{id} достаточно детален, чтобы поставить диагноз без логов
контейнера — status, exit_code, duration_s, stdout, stderr, error.
duration_s < 1 при exit_code: 1 = CLI упал мгновенно на своей проверке (напр.
авторизации), а не на реальной работе.
9. 🔴 Кратковременные задачи могут стать failed без stdout/error
В списке /tasks видны исторические задачи с exit_code: 1, duration_s ~0.7-0.8,
stdout: null, error: null — тогда как другая задача того же класса отдала
содержательный error (Claude configuration file not found at: /root/.claude.json).
Следствие: пустой stdout не значит «задача не запускалась». Смотреть на связку
status=failed + exit_code=1 + duration_s, а причину добирать из логов контейнера
(docker logs balda-executor) или повтором задачи. Не делать вывод «мост не вызывает CLI»
по пустому ответу одной задачи.
Порядок ручной приёмки (сработавший)
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8790/task \
-H 'Content-Type: application/json' -d '{"prompt":"hi"}' # → 401 (нет секрета)
# с секретом и одним Idempotency-Key дважды → один и тот же task_id, 202 затем 200
# GET /task/{id} → status/stdout/exit_code (⚠️ С секретом, иначе unauthorized)
📌 Секрет из .env в inline-команде собирать скриптом, а не литералом. Строка вида
SECRET=$(grep … .env | cut …) в инлайн-шелле ломается на кавычках и на редакции секретов
Hermes — рабочий вариант: положить в файл (write_file) и запустить bash /tmp/x.sh.
Секрет в командной строке не светится.
Тестовый скрипт: /tmp/test-bridge.sh, /tmp/probe-exec.sh (читают секрет из .env).
Проверка авторизации CLI одной задачей
# ожидаемо при незалогиненном CLI: status=failed, exit_code=1, duration_s<1,
# stdout="Not logged in · Please run /login"
# при рабочем логине: status=succeeded, реальный текст модели
/tmp/probe-exec.sh <task_id> — печатает полный JSON задачи и состояние ФС
контейнера (ls -la /root/), чтобы сразу видеть, есть ли .claude.json/креды.
Связанное
- personal/plans/balda-mm-phase4-agents — план Фазы 4 (шаги 1-2 ✅)
- personal/tech/balda-executor-mcp — MCP-сервер, который ходит в этот мост
- personal/tech/macos-docker-virtiofs-mount-limits — почему bind-mount вместо symlink
- personal/projects/balda/balda-mm-delegation-plan — общий план делегирования
~/Developer/agents-bridge— код моста
История
- 2026-09-26: создан, поднят на :8790, протестирован (auth 401, идемпотентность, сквозной вызов CLI). Логин CLI — за Alex.
- 2026-09-26 (продолжение): переведён под launchd (
com.agents.bridge,RunAtLoad+KeepAlive, pid 3063), добавленEnvironmentVariables.PATH. Шаг 3 разблокирован; проверкаKeepAliveпри падении не выполнена (отклонёнkill -9). - 2026-09-26 (шаг 5):
BRIDGE_HOST→0.0.0.0(в plist, не в.env), сервис переставлен черезbootout+bootstrap. Проверено с Kraken:curlс хоста и из контейнера вmattermost_mm-netдаютstatus: ok. Туннель не потребовался — Kraken и Mac в одной LAN. Обратный SSH-туннель на Kraken невозможен (AllowTcpForwarding no). - 2026-09-26 (MCP):
executor_healthиз контейнераbalda-executor-mcp(docker-сеть Kraken) вернул живые данные моста — связка Kraken→Mac работает. - 2026-09-28: аудит Фазы 4 живыми вызовами. Мост + туннель + контейнер исправны,
задача доходит до CLI, но падает
Not logged in · Please run /login— CLI в контейнере не авторизован, это единственный блокер Фазы 4. В хост-монтировании~/cli-auth/claude/нет.claude.json— толькоbackups/,projects/,sessions/. Добавлены §8 (чтение задач требует секрет) и §9 (failed-задачи без stdout). Разбор: personal/plans/balda-mm-phase4-agents §20.