In-memory blob-sandbox для передачи контента агентом (Docmost → Habr) + миррор картинок #243
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Контекст и проблема
Агент внутри Docmost оркестрирует встроенные Docmost-тулы (in-app AI-SDK слой) и внешний habr-mcp (отдельный проект). Сейчас, чтобы передать тело документа от одного инструмента к другому, модель вынуждена выписывать его в аргумент вызова. На статье ~30 КБ ProseMirror это упирается в лимит и рвётся:
Документ обрывается на ~27 КБ → битый JSON. Вдобавок чтение страницы через модель тоже обрезается (
_Truncated, omittedItems: 89) — то есть «передать строку побольше» не лечит в принципе.Картинки ломаются отдельно. Image-нода у Docmost (см.
client.ts:2509-2516):src— относительный внутренний путь, который отдаётGET /api/files/:fileId/:fileName(attachment.controller.ts:167) под аутентификацией Docmost. На Хабр он уезжает как есть → путь относительный/недоступный без сессии → битая картинка. ПубличныйGET /api/files/public/...требует подписанный JWT именно под этот attachmentId, для постороннего habr тоже не годится.Принцип решения
Канал «байты мимо контекста модели И мимо авторизации Docmost»: gitmost держит блоб у себя в RAM, отдаёт его анонимным HTTP GET по непрогнозируемому UUID с TTL 1 час. Через контекст агента летит только короткая ссылка.
Mapв памяти процесса gitmost. Ни диска, ни БД.ETag.src.Поток данных
Один процесс gitmost = и хранилище (RAM), и раздача (HTTP). Тул пишет в стор in-process; читает стор только HTTP-контроллер. habr знает непрозрачные URL, gitmost наружу не ходит.
Компоненты
1.
SandboxStore— новый NestJS-провайдерФайл:
apps/server/src/integrations/sandbox/sandbox.store.ts, singleton (@Injectable).id—randomUUID()(122 бита случайности).sha256считается приput, хранится в записи и отдаётся какETag— integrity-check, которым habr ловит обрезанный/битый блоб (исходный баг).SANDBOX_TTL_MS(дефолт 1 ч): ленивая проверка вget+ фоновый sweep. Скопировать паттернsweepTimer+.unref()+clearIntervalвonModuleDestroyизmcp.service.ts:91-116/packages/mcp/src/http.ts:83-94.SANDBOX_MAX_BYTES(на blob документа),SANDBOX_MAX_IMAGE_BYTES(на картинку),SANDBOX_MAX_TOTAL_BYTES(на весь стор, считать по байтам — одна статья с картинками даёт много записей). При переполнении — вытеснять старейшее или отклонятьputс внятной ошибкой.2.
GET /api/sb/:id— новый Fastify-контроллерФайлы:
apps/server/src/integrations/sandbox/sandbox.controller.ts+sandbox.module.ts(DISandboxStore). Путь под общим API-префиксом.:idUUID-регуляркой^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$— это анти-traversal / input-гигиена, не авторизация (чтобы:idне был../…);store.get(id)→ нет/протух → 404 (без stack trace наружу);Content-Type: <mime>,Content-Length,ETag: "<sha256>"— сильный валидатор (в кавычках, безW/), значением служит тот же sha256, что считается вput;If-None-Matchсовпадает с текущимETag→ 304 Not Modified без тела (блоб иммутабелен, валидатор стабилен).Fastify: для бинарной отдачи вернуть
Buffer/stream с явнымContent-Type(не пропускать через JSON-сериализатор).3. Тул
stash_page(+ миррор картинок)Добавить спеку
stashPageвSHARED_TOOL_SPECS(packages/mcp/src/tool-specs.ts) — оба слоя (MCP zod-v3 + in-app AI-SDK zod-v4) подхватят автоматически, если описание/схема совпадают; иначе определить по слою.Вход:
{ pageId: string }.Алгоритм:
Возврат — только ссылка, без тела (иначе снова засорим контекст):
packages/mcp/src/index.ts): content-item типаresource_link(НЕ embeddedresource):ai-chat-tools.service.ts): маленький объект{ uri, size, sha256, images: { mirrored, failed } }.uri = ${SANDBOX_PUBLIC_URL}/api/sb/${id}.Доступ к стору: тулу нужен только
put. Пакетpackages/mcpразвязан отapps/server→ прокинутьsandbox: { put }вDocmostMcpConfig, забиндив метод singleton-стора в config-резолвереmcp.service.ts. In-app слой инжектитSandboxStoreчерез DI (как клиент вdocmost-client.loader.ts).Generic, а не только
image: критерий — «внутренний файл-URL», а не тип ноды. Тот же механизм закрываетdrawio/excalidraw/video/file-ноды, у которыхsrcтоже/api/files/...(см.packages/editor-ext/src/lib/drawio.ts,excalidraw.ts) — иначе побьются и они.Конфиг (env, в стиле
.env.example)SANDBOX_PUBLIC_URLuri; должен быт�� доступен habr-mcp по сети (не loopback, если habr удалён)SANDBOX_TTL_MS3600000SANDBOX_MAX_BYTES8388608SANDBOX_MAX_IMAGE_BYTESMAX_IMAGE_BYTES20 MiB,client.ts:2536)20971520SANDBOX_MAX_TOTAL_BYTES134217728Токенов нет.
Контракт для habr-mcp (другая команда / другой проект)
GET {uri}без авторизационных заголовков → тело документа; сверить целостность поETag(= sha256 тела). Можно использоватьIf-None-Matchдля условных запросов.attrs.src, указывающего на sandbox-URL:GETбайты картинки из sandbox (анонимно, верныйContent-Type+ETag; сверить);attrs.srcна URL, который вернул Хабр (снятьattachmentId).http(s)-картинки (не sandbox) — на усмотрение habr.Тело документа и картинок ходит
RAM gitmost → HTTP → habr, мимо контекста модели.Безопасность
:idпроверяется UUID-регуляркой, не используется как путь.ETag= sha256 тела (сильный валидатор) — habr ловит обрезанный/битый блоб. Это integrity, не авторизация.image/svg+xml; exec-риск на стороне Хабра (он ре-хостит).Краевые случаи
stash_pageи GET → 404; агент пере-stash'ит. В описании тула: «потребление в пределах TTL и одного аптайма».SANDBOX_MAX_IMAGE_BYTES→ не зеркалится, вfailed, исходный src остаётся (видно из счётчика).SANDBOX_MAX_TOTAL_BYTES→ внятная ошибка/частичный результат с предупреждением, не молчком.src(CDN) не трогаем (regression-тест).SANDBOX_PUBLIC_URLне задан, а фича включена → падать с понятной ошибкой на старте.:id(в т.ч.../) → 404/400, до стора не доходит.Файлы
Новые:
apps/server/src/integrations/sandbox/sandbox.store.tsapps/server/src/integrations/sandbox/sandbox.controller.tsapps/server/src/integrations/sandbox/sandbox.module.tssandbox.store.spec.ts,sandbox.controller.spec.tsПравки:
packages/mcp/src/tool-specs.ts— спекаstashPage.packages/mcp/src/index.ts— хендлерstash_page(возвратresource_link); принятьsandbox.putиз config.packages/mcp—DocmostMcpConfig+ полеsandbox?: { put }.packages/mcp/src/client.ts— логикаstash_page: рекурсивный walker (какreplaceImage~L2812 /validateDocUrls~L1207) + authed-фетч (какfetchRemoteImage~L2408 /uploadImage~L2563); «collect internal-file srcs» удобно вынести вpackages/mcp/src/lib.isInternalFileUrl/normalizeFileUrl: импорт изpackages/editor-extили дубль вpackages/mcp/src/lib.apps/server/src/integrations/mcp/mcp.service.ts— прокинутьSandboxStore.putв config-резолвер.apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts— зарегистрироватьstash_page, инжектнутьSandboxStore.SandboxModuleв корневой модуль; env в.env.example.Готово, когда (DoD)
stash_page(pageId)возвращает{uri,size,sha256,images}(resource_link в MCP) — тело в контекст не попадает.GET /api/sb/:idотдаёт ровно те байты с вернымETag(= sha256);If-None-Matchсовпал → 304; после TTL → 404.stash_page→ внешнийGETзабирает документ целиком (сверка поETag/sha256), мимо модели.Content-Type/ETag→ итоговый черновик с целыми картинками./api/files/...src переписан на sandbox-URL; внешнийhttp-src не тронут; битая картинка →failed, документ не падает; дедуп; не-UUID:id→ 404/400; TTL-протухание; каппы;ETag/If-None-Match→304; «тул вернул ссылку, а не тело».lint+build+ спеки зелёные; env задокументированы.Вне скоупа (явно НЕ делаем)
POST /api/sb,create_page_from_blob.SANDBOX_TOKEN,X-Sandbox-Token).Это делалось под https://github.com/vvzvlad/habr-mcp, после реализации сравни с контрактом и кодом, чтобы оно работало вместе. Внутри кода gitmost не должно быть ничего про хабр, это фича абстрактная, просто реализация передачи блобов.
Ghost referenced this issue2026-06-28 17:19:21 +03:00
Ghost referenced this issue2026-06-28 18:29:08 +03:00
Ghost referenced this issue2026-06-28 20:22:15 +03:00