291 lines
17 KiB
Markdown
Executable File
291 lines
17 KiB
Markdown
Executable File
# 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 |
|
||
|
||
```bash
|
||
# установка/переустановка
|
||
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` только так:
|
||
```bash
|
||
/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` открыт (это осознанно — по нему проверяют
|
||
и туннель, и мониторинг), а **чтение задач тоже закрыто**:
|
||
|
||
```bash
|
||
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»
|
||
по пустому ответу одной задачи.
|
||
|
||
---
|
||
|
||
## Порядок ручной приёмки (сработавший)
|
||
|
||
```bash
|
||
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 одной задачей
|
||
|
||
```bash
|
||
# ожидаемо при незалогиненном 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.
|