[2026-09-26] eagle: family/how-to/jellyfin-config.md personal/index.md personal/plans/balda-mm-endtoend-plan.md personal/projects/balda/balda-kraken-mm-deploy.md personal/projects/balda/balda-mm-delegation-plan.md personal/tech/balda-go-fx-pitfalls.md personal/tech/music-tags-repair-truenas.md personal/tech/yandex-music-api.md personal/yandex-music-library.md

This commit is contained in:
Alexey Martemyanov committed 2026-09-26 13:56:32 +06:00
1 parent 4109797f6b
commit f9b55abb8c
9 files changed
+2190 -94

No files matched your search

+320 -1
View File
@@ -1,6 +1,7 @@
# Balda — Go/fx pitfalls и метод правок (зеркало скилла)
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход).
> Создано 2026-09-26. Обновлено 2026-09-26 (сессия реализации MM-хендлера, полный проход +
> **приёмка сквозного пути**, Фаза 3 закрыта).
> Добавлены: **`bad_seq` — настоящая причина тишины WS** (§«WS-фрейм без seq»),
> **`optional:"true"` скрывает отсутствующую зависимость** (§«Дефект 5»),
> **`canonicalSubject` и неизвестный транспорт** (§«auth: subject-based»),
@@ -9,6 +10,8 @@
> **`FROM scratch` без CA** (§«scratch и TLS»),
> **`principal` с префиксом в БД** (§«Дефект 7»),
> **`state.db` читать вместе с WAL** (§«Метод: чтение sqlite-состояния»).
> 🔴 **Новое в этой сессии (приёмка):** §«Приёмка агент-проверяемый» — как доказать ответ
> бота без GUI; §«Mattermost Team Edition: что недоступно» — push невозможен.
> Уточнён §«авторизация — ОДИН механизм»: челлендж НЕ был причиной №1, им был `bad_seq`.
> Зеркало секции скилла `devops/docker-balda-service`, добавленной 2026-09-26.
> Источник: работа над Mattermost-адаптером Балды (Фаза 2) + деплой на Kraken (Фаза 3).
@@ -19,6 +22,62 @@
---
## ✅ Приёмка, проверяемая агентом (без GUI-браузера)
Требование Alex: «приёмка = видно в UI, не "контейнер Up" и не "лог said accepted"».
Агент без браузера **не может** посмотреть UI — но может верифицировать ответ бота
однозначно, читая **ленту канала** REST'ом и сверяя `user_id` автора:
```bash
ssh kraken 'curl -s -H "Authorization: Bearer *** /home/kraken/balda/secrets/mm-bot-token)" \
"http://localhost:8065/api/v4/channels/7bjw4g7o1jdsbmjbqd649xxgaw/posts?per_page=4" \
| jq -r ".order[] as \$o | .posts[\$o] | \"\(.user_id) | \(.message)\""'
```
Ожидаемый вид — пост человека и **пост с `user_id` = bot_user_id**:
```
c6udeze8dfrdxm5jbgq55bd58r | @balda reply with a single word
edxrgupoxiyg5gzmr79tjypbme | работает ← это и есть доказательство
```
📌 Это заменяет «видно в UI»: `user_id` автора поста — однозначный факт, не интерпретация.
⚠️ `post_user_id` в логах ингресса **не** подходит: там id отправителя запроса, не автора ответа.
⚠️ Не отчитываться «готово» по факту `delivery settled` в логах — доставляется ли пост, видно
только из ленты.
---
## 🔴 Mattermost Team Edition: что недоступно (проверено)
`mattermost version` → `Build Enterprise Ready: false`. На TE **невозможно** включить push
в официальных мобильных приложениях — это архитектура продукта, не настройка.
| Что | Факт |
|---|---|
| **Тумблер push** | Живёт в `EmailSettings.SendPushNotifications`, **не** в `ServiceSettings` — `mmctl config get ServiceSettings.EnablePushNotifications` даёт `Error: invalid key` |
| **Официальный push-прокси** | Жив (HTTP 200), но для TE-`DiagnosticId` отвечает `Did not send message because of missing platform property` — APNs/FCM-сертификаты только у лицензированных EE |
| **Свой MPNS** | Требует **собственные подписанные сборки** мобильных приложений + Apple Developer ($99/год) + FCM. Официальные клиенты прошиты на bundle ID Mattermost Inc. и к своему MPNS не подключатся |
| **SMTP-почта** | Единственный путь без лицензии. `SendEmailNotifications: true` уже стоит, но SMTP не настроен (`localhost:10025`, на хосте ни одного слушателя) |
🔴 **Проба прокси — читать ТЕЛО, не статус.** `curl -X POST https://push.mattermost.com/api/v1/send_push`
возвращает **HTTP 200** даже на невалидный `server_id`; решение в теле: `{"status":"FAIL","error":"…"}`.
HTTP-код здесь — ложный сигнал успеха.
⚠️ `DiagnosticId` ищется так (таблица **`systems`**, не `Systems`; колонка `ServerId` отсутствует):
```bash
docker exec mm-postgres psql -U mmuser -d mattermost -t -c \
"SELECT name, value FROM systems WHERE name ILIKE '%diagnostic%';"
```
🔴 **Кавычки внутри `psql -c` в ssh-строке ломаются**: `name IN ('DiagnosticId')` уходит в
shell до psql → Postgres видит `column "DiagnosticId" does not exist`. Экранировать `'"'"'`
либо использовать `ILIKE` с шаблоном без значения в кавычках.
📌 MM-стек на Kraken: `/home/kraken/mattermost/docker-compose.yml`, 3 сервиса
(`postgres` → `mm-postgres`, `mattermost`, сеть `mm-net` → `mattermost_mm-net`).
БД: `docker exec mm-postgres psql -U mmuser -d mattermost`.
---
## 🔴 `go build` = exit 0 ≠ рабочий транспорт
Компилятор не видит ошибок DI-графа. `go build ./...` проходит, а приложение падает
@@ -623,8 +682,268 @@ conn.WriteJSON(map[string]any{ // ← 🔴 а эт
---
## 🔴 Дефект 6: точка входа онбординга, закрытая проверкой доступа
Найдено 2026-09-26. `/start owner=<token>` в DM получил от бота
**«You are not allowed to use Balda in this channel.»**
**Причина:** `HandleCommand` проверял доступ **до** обработки, а владельца ещё нет — тупик:
привязаться нельзя, потому что не авторизован; авторизоваться нельзя, потому что не привязан.
Владелец в canonical store привязывается **этой же командой**, поэтому она обязана быть
доступна неавторизованному.
**Неправильно:**
```go
if !h.canAccess(ctx, cmd.SenderID) {
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
}
```
**Правильно** (образец — `zulip_ingress.go:172`):
```go
if cmd.Command != commandStart && !h.canAccess(ctx, cmd.SenderID) {
return h.sendPlain(ctx, cmd.Locator, accessDeniedText)
}
```
⚠️ `commandStart` **уже объявлена** в пакете `handlersfx` (приходит из telegram/zulip-веток).
Своя константа даёт `commandStart redeclared in this block`. Использовать общую.
📌 **Правило для нового транспорта:** онбординг-команда — единственная, что доступна
неавторизованному пользователю. Проверять это явно, иначе транспорт невозможно
забутстрапить.
---
## 🔴 Дефект 7: `principal` с транспортным префиксом в `balda_user_bindings`
Найдено 2026-09-26. `/start owner=<token>` вернул успех («You are now registered as the bot
owner»), но каждое обращение к боту оставалось `settlement_stage="unauthorized"`.
**Механизм.** Авторизация сверяет subject вида `<channel>:<principal>`. `canonicalSubject()`
**разрезает** его по первому `:` и **отбрасывает** префикс:
```go
if before, after, ok := strings.Cut(trimmed, ":"); ok {
channelType = strings.ToLower(strings.TrimSpace(before))
principal = strings.TrimSpace(after) // ← префикс отрезан
}
```
А `GetUserByBinding` делает **точное** сравнение:
```go
return s.getUser(ctx, `b.channel_type = ? AND b.principal = ?`, channelType, principal)
```
Если в БД записался `principal = 'mattermost:<user_id>'` при `channel_type='mattermost'`,
то поиск по `principal='<user_id>'` не находит **ничего** — молча, без ошибки.
🔴 **Правило:** в `balda_user_bindings` `principal` хранится **БЕЗ** транспортного префикса.
Префикс живёт только в `channel_type`. Нельзя собирать subject тем же хелпером, которым
потом нормализуешь: `userSubject()` в `start/handler.go` оборачивает principal в
`<transport>:<principal>` — если на вход уже пришёл обёрнутый principal, выходит двойная
обёртка и запись уезжает с префиксом.
**Диагностика — прочитать таблицу:**
```bash
sudo -n sqlite3 /srv/…/docker/balda/state/state.db \
"SELECT channel_type, principal FROM balda_user_bindings;"
# правильно: mattermost | c6udeze8dfrdxm5jbgq55bd58r
# неправильно: mattermost | mattermost:c6udeze8dfrdxm5jbgq55bd58r
```
**Лечение** (`tools/mm-fix-owner-principal.sh`: бэкап → стоп → UPDATE → старт):
```sql
UPDATE balda_user_bindings
SET principal = substr(principal, length(channel_type) + 2)
WHERE channel_type = 'mattermost' AND principal LIKE channel_type || ':%';
```
⚠️ Штатного пути **нет**: `AttachBinding` отказывает при существующей привязке
(`count != 0` → `ErrBindingAlreadyAssigned`), detach-API отсутствует вовсе. Правка БД —
вынужденная мера, делать с бэкапом и **остановленным** контейнером (sqlite не любит
конкурентной записи).
---
## 🔴 Провайдер: `base_url` в YAML мёртв, работает только `OPENAI_BASE_URL`
Найдено 2026-09-26. Симптом (образ `0.1.7`):
```json
{"component":"balda.turn_dispatcher",
"error":"agent run: send openai request: Post \"https://api.openai.com/v1/chat/completions\":
tls: failed to verify certificate: x509: certificate signed by unknown authority"}
```
Запрос уходил на **`api.openai.com`**, хотя в `config.yaml` стоял
`base_url: "https://api.deepseek.com/v1"`.
**Причина:** тип `LocalAPIConfig` в `github.com/normahq/runtime/v2@v2.0.11/agentconfig/config.go`
имеет **только** `api_key` и `model`. Поля `base_url` в схеме **нет**:
```go
type LocalAPIConfig struct {
APIKey string `json:"api_key,omitempty" yaml:"api_key,omitempty" mapstructure:"api_key"`
Model string `json:"model,omitempty" yaml:"model,omitempty" mapstructure:"model"`
}
```
Базовый URL читается **только из env** — `hostedagent/openai.go`:
```go
const defaultOpenAIBaseURL = "https://api.openai.com/v1"
func openAIBaseURL() string {
if u := os.Getenv("OPENAI_BASE_URL"); u != "" {
return strings.TrimRight(u, "/")
}
return defaultOpenAIBaseURL
}
```
📌 **Правило:** для любого OpenAI-совместимого провайдера (DeepSeek, локальные gateway и т.п.)
базовый URL задавать **через `OPENAI_BASE_URL`** в `.env` или `environment:` compose.
Ключ `base_url` в YAML **выглядит рабочим, но не делает ничего** — молчаливая ловушка.
⚠️ Сама переменная попадает в процесс только если `env_file`/`environment` настроены.
**Фикс:** `OPENAI_BASE_URL=https://api.deepseek.com/v1` в `/home/kraken/balda/.env`
(`tools/mm-add-base-url.sh`, идемпотентный, с бэкапом).
---
## 🔴 `FROM scratch` и TLS: нет доверенных корней → любой HTTPS падает
`Dockerfile.release` Балды собирается из `scratch`. В финальном образе **нет
`/etc/ssl/certs`**, поэтому попытка установить любое TLS-соединение заканчивается:
```
tls: failed to verify certificate: x509: certificate signed by unknown authority
```
Ошибка **обманчива**: выглядит как «удалённый сервер с неверным сертификатом», а на деле
в образе физически нет файла с корневыми CA — Go не может построить цепочку доверия.
Симптом проявился ровно один раз, на первом исходящем HTTPS (запрос к LLM).
**Фикс — дельта `tools/deltas-mattermost-22-ca.json`:**
```dockerfile
FROM scratch
# CA bundle: the scratch base has no trust store, so any HTTPS call (the LLM
# provider in particular) fails with 'x509: certificate signed by unknown
# authority'. The builder stage is a Debian image and carries the bundle.
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /out/balda /usr/local/bin/balda
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENTRYPOINT ["/usr/local/bin/balda"]
```
📌 **Правило:** любой `scratch`/`distroless`-образ, делающий исходящие HTTPS-запросы, обязан
содержать `ca-certificates.crt` **и** выставлять `SSL_CERT_FILE` (у distroless путь тот же).
Копировать прямо из builder'а — он на Debian и бандл уже там.
⚠️ Альтернатива для локального дебага: `SSL_CERT_FILE` с хоста через bind-mount. Для прода —
копировать в образ.
---
## Метод: чтение sqlite-состояния Балды (образ `scratch`)
В образе **нет** ни `sh`, ни `sqlite3`, поэтому `docker exec` невозможен в принципе:
```
OCI runtime exec failed: exec failed: unable to start container process:
exec: "sh": executable file not found in $PATH
```
**Рабочий порядок:**
1. Файлы состояния: `<state_dir>/state.db` + `state.db-wal` + `state.db-shm`, владелец — **root**.
2. `sqlite3` **есть на хосте** Kraken (`/usr/bin/sqlite3`), но не в контейнере.
3. Для остановленного контейнера — прямой `sqlite3` под `sudo -n`.
4. Для живого — `strings` (только просмотр, без записи).
🔴 **Читать `state.db` ВМЕСТЕ с `state.db-wal`.** Свежие записи живут в WAL, а основной файл
выглядит пустым. Прямая потеря времени: таблица `balda_user_bindings` казалась пустой, хотя
привязка владельца уже была записана — она лежала в `-wal`.
```bash
STATE=/srv/…/docker/balda/state
for f in state.db state.db-wal state.db-shm; do
echo "--- $f"
sudo -n strings "$STATE/$f" 2>/dev/null \
| grep -oE "(telegram|mattermost|slackagent|zulip):[A-Za-z0-9_-]+" | sort -u
done
```
**Полезные ключи KV внутри `state.db`:**
- `balda.appowner_auth_token` — owner auth token (32 символа), нужен для `/start owner=<token>`;
- `balda_user_bindings` — привязки, `principal` **без** префикса (см. §Дефект 7);
- `balda_user_binding_claims` — claim'ы, `generated_binding_count`, `role IN ('administrator','operator')`.
⚠️ `sudo` через ssh требует `< /dev/null`, иначе `Connection closed by remote host`.
⚠️ Правка каталога данных (`sudo cp -a state <dir>`) уходит на апрув и **блокируется** по
таймауту. Точечный `UPDATE` одного поля такого апрува не требует — предпочтителен.
---
## 🔴 Mattermost WS: `bad_seq` — пинг без `seq` убивает подписку молча
Найдено 2026-09-26 диагностическим логом каждого кадра (`deltas-mattermost-15-diag.json`).
**Это и была настоящая причина «connected, но тишина»** — до неё ошибочно считалось, что
виноват `authentication_challenge` (он тоже вреден, но лишь как усугубляющий фактор).
MM отвечал раз в 30 секунд, ровно на каждый пинг:
```json
{"status":"FAIL","error":{"id":"api.web_socket_router.bad_seq.app_error",
"message":"Invalid sequence for WebSocket message."}}
```
**Механизм:** Mattermost требует **монотонный `seq` в КАЖДОМ клиентском фрейме**. Балда слала
`{"action":"ping"}` **без `seq`** → MM помечал соединение сломанным и **прекращал доставлять
`posted`**, при этом **сокет не закрывал**. Поэтому `mattermost websocket connected` в логе
остаётся, а событий нет. Особенно коварно после снятия челленджа: счётчик `seq` пропадал
вместе с ним, и **ни один** исходящий фрейм его не нёс.
**Фикс** (`deltas-mattermost-16-seq.json`): поле `seq int64`, `nextSeq()` под мьютексом,
единый `writeEvent(conn, action, data)`, сброс `seq = 0` при новом соединении, пинг через
`writeEvent`, а фрейм со `status:FAIL` логируется **warn**, а не игнорируется.
📌 **Правило:** любая отправка в MM-сокет — **только** через `writeEvent`. «Сырой»
`conn.WriteJSON`/`WriteMessage` без `seq` = тихо убитая подписка.
📌 **Общий урок:** лог «connection established» не значит, что подписка жива. Проверять
**фактом получения события**, а не фактом коннекта. Временный лог каждого кадра окупается
сразу — и его стоит оставить после фикса.
---
## Связанные заметки
- [[personal/projects/balda/balda-mattermost-connector]] — Фаза 2, где всё это найдено
- [[personal/projects/balda/balda-kraken-mm-deploy]] — Фаза 3, деплой на Kraken
- [[personal/plans/balda-mm-endtoend-plan]] — сквозной путь `@balda` → ответ
- [[personal/projects/balda/balda-mm-delegation-plan]] — общий план
- [[personal/tech/hermes-secret-redaction-bypass]] — маскировка секретов в tool-выводе
---
## Диаграмма потока ингресса (для отладки нового транспорта)
```
MM websocket frame
↓ readLoop (frame.messageType == TextMessage?)
↓ handleFrame ← лог «event decoded»; status:FAIL → warn
↓ dispatchPosted (IsBotEcho / IsSystemPost / IsDeletedPost → drop с логом)
↓ processPosted (MentionsBot / StripMention → drop с логом)
↓ InboundProcessor.ProcessInbound ← 🔴 ТУТ был nil (Дефект 5)
↓ ingressapp.Service.Process
├─ Authorizer.Authorize ← subject + canonicalSubject (Дефекты 3, 7)
├─ SessionPreparer.Prepare ← GetSession → RestoreSession → EnsureSession
└─ Dispatcher.Dispatch ← durable envelope
↓ turn_dispatcher → provider (OPENAI_BASE_URL + CA, Дефект F)
```
**Точки отказа, которые надо проверять по порядку** — сверху вниз, по логам:
`frame received` → `event decoded` → `posted event accepted` → `post accepted for processing`
→ `conversational ingress settled` (`settlement_stage`!) → `assembled provider user content`
→ `turn task failed`. Пока нет строки `assembled provider user content` — провайдер не виноват.