15 KiB
15 KiB
Follow-ups код-ревью фичи ai-chat
Контекст: мульти-аспектное ревью встроенного AI-агента (диапазон коммитов
6e5d0300..4868ca8e, вся фича ai-chat) прошло чисто по безопасности,
регрессиям и конвенциям. Ниже — находки, которые НЕ блокируют merge, но
должны быть закрыты: пробелы в тестах на критичном по безопасности коде,
доступность с клавиатуры, устаревшая документация и мелкие рефакторинги.
Сгруппировано по приоритету. Каждая запись: что → где (file:line) → почему →
фикс.
Сознательно НЕ входят в этот файл (вынесены отдельно): warning про неусечённый
реплей tool-выводов в ai-chat.service.ts и архитектурное предложение про
дублирование набора инструментов между in-app агентом и packages/mcp.
Приоритет 1 — тесты на критичном по безопасности коде (warning)
1.1 Шифрование ключей провайдеров (AES-256-GCM) — ноль тестов
- Где:
apps/server/src/integrations/crypto/secret-box.ts—encryptSecret(:36-48),decryptSecret(:51-81), сообщение об ошибке (:78). Spec-файла нет (подтверждено grep'ом по*.spec.ts). - Почему: это единственная защита API-ключей провайдеров в покое. Не
проверено: round-trip
encrypt → decryptвозвращает исходный текст; два шифрования одного текста дают разные блобы (random salt+iv, layoutbase64(salt | iv | authTag | ciphertext)); веткаcatchбросает ожидаемую ошибку «APP_SECRET may have changed» на испорченном/обрезанном блобе или неверном ключе (на это сообщение опирается UI). Ошибка в смещениях layout или регресс auth-tag молча испортит все сохранённые креды. - Фикс:
secret-box.spec.ts, 4 кейса — (1) round-trip equality; (2) два encrypt одного входа → разные блобы, оба декриптятся; (3) decrypt подделанного ciphertext / флипнутого байта auth-tag → throw с нужным сообщением; (4) decrypt под другимAPP_SECRET→ throw.EnvironmentServiceтривиально стабается (getAppSecret).
1.2 SSRF-guard — ветки allow/deny полностью не покрыты
- Где:
apps/server/src/core/ai-chat/external-mcp/ssrf-guard.ts—isIpAllowed(:40),isUrlAllowed(:60-104);isIpAllowedвызывается для IP-литерала (:80) и для каждого DNS-резолва (:97). - Почему: единственная защита от SSRF для admin-задаваемых URL внешних
MCP-серверов; тестов нет. Каждая непокрытая ветка = реальный эксплойт:
loopback (127.0.0.1, ::1), link-local/metadata (169.254.169.254), private
(10/172.16/192.168), CGNAT (100.64/10), ULA (fc00::/7), unspecified,
IPv4-mapped IPv6, не-http(s) схема, невалидный URL, DNS-rebinding (любой
резолвнутый адрес приватный ⇒ block).
isIpAllowed— чистая синхронная функция. - Фикс:
ssrf-guard.spec.ts—isIpAllowedпо каждому блокируемому классу- публичный IP (allow);
isUrlAllowed— bad-scheme, invalid-url, IP-литерал-private и (с мокомdns.lookup) кейс rebinding, где резолвнутый адрес приватный.
- публичный IP (allow);
1.3 assistantParts() — логика «сохранить ошибки/tool-calls в истории» без тестов
- Где:
apps/server/src/core/ai-chat/ai-chat.service.ts—assistantParts(:430-495), родственныеserializeSteps(:610),rowToUiMessage. Spec'а у сервиса нет. - Почему: чистая функция, чей вывод определяет, переиграется ли диалог.
Ключевая ветка (
:472-486) эмитит синтетическийoutput-errorдля tool-call без пары — чтобыconvertToModelMessagesне бросилMissingToolResultsErrorна следующем ходу. Это суть фиксов видимости ошибок (dbd83b5a/4868ca8e). Регресс, убравший пару, молча вернёт краш. Не покрыты также ветки: step с текстом vs без (:451-453,:489-492), call с результатом (output-available,:463-471) vs без, skip битого call (!toolName || !toolCallId,:461). - Фикс: экспортировать чистые хелперы (или тонкая обёртка) и в spec
проверить: парный вызов →
output-available; непарный →output-error; skip битых; fallback на единственныйtextпри отсутствии step-текста.rowToUiMessageпредпочитаетmetadata.partsнадcontent. Тест на ветку непарного вызова обязан падать на pre-fix коде.
1.4 (suggestion) Ветки парсинга JSON-строковых node-аргументов не покрыты
- Где:
apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts—patchNode(:686-693),insertNode(:745-752),updatePageJson(:800-809); сообщения об ошибке:690,:749,:804. Существующийai-chat-tools.service.spec.tsпокрывает только guardraildeletePage+ наличие инструментов. - Почему: фикс
59b99dbaдобавил coercion string→object (то, что чинилоinsert_nodeпод OpenAI-tool-calls). Невалидная JSON-строка бросает «node was a string but not valid JSON» / «content was a string…»;updatePageJsonразличает undefined/null (title-only) vs object vs string-parse. Регресс, убравший parse, молча вернёт падениеinsert_nodeпод OpenAI. - Фикс: в существующий spec (он уже стабает фейковый клиент) добавить:
JSON-строковый
nodeпарсится и форвардится как объект; невалидная строка → throw с нужным сообщением;updatePageJsonсcontent === undefinedфорвардитdoc === undefined(title-only), объект проходит как есть.
1.5 (suggestion) Фильтр размерности / пустые spaces в поиске эмбеддингов не покрыты
- Где:
apps/server/src/database/repos/ai-chat/page-embedding.repo.ts—searchByEmbedding(:143), early-return на пустомspaceIds(:149), фильтрmodel_dimensions = queryEmbedding.length(:154+ where в запросе). - Почему: early-return на пустых spaceIds — путь access-scoping с нулевым результатом; фильтр размерности существует, чтобы избежать pgvector dimension-mismatch, когда остались строки от ранее настроенной модели эмбеддингов. Регресс, убравший фильтр, вернёт runtime-краш pgvector.
- Фикс: минимум — assert, что
searchByEmbedding(ws, vec, [], n)→[]без обращения к БД (ветка чистая). При наличии тест-БД — кейс со смешанными размерностями: скорятся только строки той же размерности.
Приоритет 2 — доступность и документация (suggestion)
2.1 Два новых кликабельных div без клавиатурной доступности (a11y)
- Где:
apps/client/src/features/ai-chat/components/ai-chat-window.tsx:342-354(заголовок «Chat history») иapps/client/src/features/ai-chat/components/conversation-list.tsx:107-119(строка диалога,onClickна:118). - Почему: несемантические элементы с
onClick, но безrole/tabIndex/onKeyDown— с клавиатуры/скринридером историю не развернуть и прошлый чат не открыть. Это ниже планки самого проекта:apps/client/src/features/comment/components/comment-list-item.tsxиспользуетrole="button", и бейдж AI-агента, добавленный в этом же изменении (apps/client/src/features/page-history/components/history-item.tsx:77-79), корректно ставитrole="button"+tabIndex={0}+ обработку Enter/Space. - Фикс: применить тот же паттерн к обоим элементам (или Mantine
UnstyledButton).
2.2 Устаревший doc-комментарий перечисляет 9 инструментов из текущих ~40
- Где:
apps/client/src/features/ai-chat/utils/tool-parts.tsx:1-10(список инструментов на:8-10). - Почему: комментарий описывает старый набор; после «expose full Docmost
toolset» и
drop updateCommentвводит в заблуждение. Не баг — дружелюбные подписиtoolLabelKeyвсё равно только у перечисленных, остальные идут в generic-ветку «Ran tool {{name}}». - Фикс: заменить жёсткий список на «см.
ai-chat-tools.service.ts» (или пометить, что дружелюбные подписи только у инструментов изtoolLabelKey).
2.3 Реализация secret-box противоречит схеме крипто в плане
- Где:
apps/server/src/integrations/crypto/secret-box.ts:11-48vsdocs/ai-agent-chat-plan.md§5.3 / §6.3. - Почему: код использует per-record случайную соль
(
scryptSync(APP_SECRET, salt, 32)) и layoutbase64(salt | iv | authTag | ciphertext); план описывает фиксированную строковую соль'ai-provider'и layout без сегмента соли. Реализация лучше, но план теперь описывает не те байты на диске — введёт в заблуждение при написании ротации/отладке decrypt. План помечен «иллюстративным», поэтому suggestion. - Фикс: обновить §5.3 / §6.3 под фактический layout.
Приоритет 3 — стабильность и рефакторинг (suggestion)
3.1 Новый чат, упавший на первом ходу, не «усыновляет» созданный сервером chat id
- Где:
apps/client/src/features/ai-chat/components/chat-thread.tsx:129-137(useChatсonFinishна:136, безonError). Целевой колбэк —onTurnFinishedвapps/client/src/features/ai-chat/components/ai-chat-window.tsx:154-157(инвалидируетAI_CHATS_RQ_KEY). - Почему: в AI SDK v6
onFinishне срабатывает при ошибке стрима, поэтомуonTurnFinished()не вызывается. Сервер же уже создал строку чата и сохранил error-сообщение — но клиент не инвалидирует список чатов и не подхватывает новый id: ошибочный чат не появляется в истории до постороннего refresh. Alert с ошибкой показывается, так что это UX-несогласованность, не потеря данных. - Фикс: передать в
useChatonError, который тоже вызываетonTurnFinished()(или инвалидируетAI_CHATS_RQ_KEY+ подхватывает новый id).
3.2 Дублированный хелпер isToolPart в двух компонентах
- Где:
apps/client/src/features/ai-chat/components/message-item.tsx:16иapps/client/src/features/ai-chat/components/message-list.tsx:15— идентичноеtype.startsWith("tool-") || type === "dynamic-tool". Оба уже импортируют изutils/tool-parts.tsx. - Почему: копии молча разойдутся, если AI SDK добавит ещё один tool-part-дискриминатор.
- Фикс: экспортировать
isToolPartодин раз изtool-parts.tsx(рядом сgetToolName), импортировать в оба компонента, локальные определения удалить.
3.3 Объект initialValues формы продублирован дословно
- Где:
apps/client/src/features/workspace/components/settings/components/ai-mcp-server-form.tsx—useForm({ initialValues: {...} })(:75-82) и эффект re-hydrationform.setValues({...})(:87-95): один и тот же 6-полевой объект изserver. - Почему: должны меняться синхронно; добавить поле в одно и забыть второе —
лёгкий баг. (В соседнем
ai-provider-settings.tsxэтой проблемы нет — там initialValues константны, а эффект мапит изsettings.) - Фикс: вынести
buildInitialValues(server)и звать в обоих местах.
3.4 Идиома форматирования ошибки провайдера дублирует существующий хелпер
- Где:
apps/server/src/core/ai-chat/ai-chat.service.ts:274-275и:338-339— инлайнe?.statusCode ? \${e.statusCode}: ${e.message}` : e.message`. - Почему: в
apps/server/src/integrations/ai/ai-error.util.tsуже есть общийdescribeProviderError(err)(импортируется вapps/server/src/integrations/ai/ai.service.ts:14, используется на:193,:210). Два места вai-chat.service.tsпереизобретают его инлайном — формат может разойтись. - Фикс: заменить оба инлайн-места на
describeProviderError(err)(при необходимости расширив хелпер fallback-аргументом), чтобы формат ошибок провайдера был единым.