In-memory blob-sandbox для передачи контента агентом (Docmost → Habr) + миррор картинок #243

Closed
opened 2026-06-28 03:38:41 +03:00 by Ghost · 1 comment

Контекст и проблема

Агент внутри Docmost оркестрирует встроенные Docmost-тулы (in-app AI-SDK слой) и внешний habr-mcp (отдельный проект). Сейчас, чтобы передать тело документа от одного инструмента к другому, модель вынуждена выписывать его в аргумент вызова. На статье ~30 КБ ProseMirror это упирается в лимит и рвётся:

"Не удалось разобрать doc как JSON: Expecting ',' delimiter: line 1 column 27637"

Документ обрывается на ~27 КБ → битый JSON. Вдобавок чтение страницы через модель тоже обрезается (_Truncated, omittedItems: 89) — то есть «передать строку побольше» не лечит в принципе.

Картинки ломаются отдельно. Image-нода у Docmost (см. client.ts:2509-2516):

{ "type": "image", "attrs": { "src": "/api/files/<attachmentId>/<file>", "attachmentId": "<uuid>", "width": ... } }

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. Ни диска, ни БД.
  • Капабилити = сам UUID-адрес + короткий TTL + TLS. Никаких токенов. Утёкший URL через час мёртв, угадать нереально.
  • Блоб иммутабелен (адресуется случайным id, содержимое под id не меняется) → его sha256 — идеальный сильный валидатор ETag.
  • Только прямое направление (Docmost → Habr). Обратки нет.
  • Картинки идут тем же каналом: gitmost зеркалит их в sandbox и переписывает src.

Поток данных

                       agent context (видит только ссылку)
                                  ▲              │ uri
                        resource_link/uri        ▼
 ┌─ gitmost (apps/server, NestJS+Fastify, один процесс) ──────────────┐
 │   SandboxStore (RAM: Map<uuid,{buf,mime,sha256,expiresAt}>, TTL 1ч) │
 │      ▲ put() локально              ▲ get() локально                 │
 │      │                              │                               │
 │   stash_page tool             GET /api/sb/:uuid (controller)        │
 │   (doc + миррор картинок)                                           │
 └──────────────────────────────────────┼─────────────────────────────┘
                                         │ анонимный HTTP GET
                                   habr-mcp (отдельный проект)
                                   тянет doc + картинки, ре-хостит у себя

Один процесс gitmost = и хранилище (RAM), и раздача (HTTP). Тул пишет в стор in-process; читает стор только HTTP-контроллер. habr знает непрозрачные URL, gitmost наружу не ходит.


Компоненты

1. SandboxStore — новый NestJS-провайдер

Файл: apps/server/src/integrations/sandbox/sandbox.store.ts, singleton (@Injectable).

import { randomUUID } from "node:crypto"; // already used in packages/mcp/src/http.ts

// In-RAM, process-local blob store. No disk, no DB. Ephemeral by design.
interface SandboxEntry { buf: Buffer; mime: string; sha256: string; expiresAt: number; }

class SandboxStore {
  private readonly map = new Map<string, SandboxEntry>();
  // id = randomUUID(); the unguessable id IS the read capability — no token.
  put(buf: Buffer, mime: string): { id: string; sha256: string; size: number; expiresAt: number };
  get(id: string): SandboxEntry | undefined; // undefined if missing OR expired (lazy check)
}
  • idrandomUUID() (122 бита случайности).
  • sha256 считается при put, хранится в записи и отдаётся как ETag — integrity-check, которым habr ловит обрезанный/битый блоб (исходный баг).
  • TTL = SANDBOX_TTL_MS (дефолт 1 ч): ленивая проверка в get + фоновый sweep. Скопировать паттерн sweepTimer + .unref() + clearInterval в onModuleDestroy из mcp.service.ts:91-116 / packages/mcp/src/http.ts:83-94.
  • RAM-guard: каппы 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 (DI SandboxStore). Путь под общим API-префиксом.

  • валидировать :id UUID-регуляркой ^[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 наружу);
  • иначе 200, body = сырые байты, заголовки Content-Type: <mime>, Content-Length, ETag: "<sha256>" — сильный валидатор (в кавычках, без W/), значением служит тот же sha256, что считается в put;
  • поддержать условный запрос: если If-None-Match совпадает с текущим ETag304 Not Modified без тела (блоб иммутабелен, валидатор стабилен).
  • Никаких токенов, никаких 401. habr дёргает URL без авторизационных заголовков.

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 }.

Алгоритм:

1. getPageRaw(pageId) -> content; сделать deep-clone для перезаписи.
2. Рекурсивно обойти все ноды (включая вложенные в callouts/tables) — переиспользовать
   walker из replaceImage (~client.ts:2812) / validateDocUrls (~client.ts:1207).
   Собрать ноды, у которых attrs.src — ВНУТРЕННИЙ файл-URL.
3. Детект внутреннего URL: normalizeFileUrl + isInternalFileUrl (префиксы /api/files/ и /files/,
   см. packages/editor-ext/src/lib/media-utils.ts и utils.spec.ts). Внешние http(s) src НЕ трогаем.
4. Дедуп по attachmentId/src. Для каждого уникального внутреннего src:
   - authed-фетч байтов с loopback: GET ${apiUrl}/files/<id>/<file>
     (переиспользовать auth DocmostClient + cap + 30s timeout + SSRF-границу,
      как в fetchRemoteImage ~client.ts:2408 / uploadImage ~client.ts:2563);
   - mime берём из Content-Type ответа;
   - SandboxStore.put(bytes, mime) -> uri;
   - в клоне node.attrs.src = uri (остальные attrs — width/align — сохранить;
     attachmentId можно оставить, Хабр его всё равно перезапишет).
   - параллелить с ограничением (4–6), таймаут на картинку.
   - фетч одной картинки упал -> НЕ валить документ: оставить исходный src, counter failed++.
5. SandboxStore.put(JSON.stringify(rewrittenContent), "application/json") -> doc uri.
6. Вернуть ссылку на документ + маленький счётчик по картинкам.

Возврат — только ссылка, без тела (иначе снова засорим контекст):

  • MCP-слой (packages/mcp/src/index.ts): content-item типа resource_link (НЕ embedded resource):
    return { content: [{ type: "resource_link", uri, name: "page.json", mimeType: "application/json", size }] };
    
  • in-app слой (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_URL базовый URL для uri; должен быт�� доступен habr-mcp по сети (не loopback, если habr удалён)
SANDBOX_TTL_MS время жизни блоба 3600000
SANDBOX_MAX_BYTES кап на blob документа 8388608
SANDBOX_MAX_IMAGE_BYTES кап на картинку (ориентир — MAX_IMAGE_BYTES 20 MiB, client.ts:2536) 20971520
SANDBOX_MAX_TOTAL_BYTES суммарный кап стора (RAM-guard) 134217728

Токенов нет.

Контракт для habr-mcp (другая команда / другой проект)

  1. GET {uri} без авторизационных заголовков → тело документа; сверить целостность по ETag (= sha256 тела). Можно использовать If-None-Match для условных запросов.
  2. Обойти ноды документа; для каждого attrs.src, указывающего на sandbox-URL:
    • GET байты картинки из sandbox (анонимно, верный Content-Type + ETag; сверить);
    • загрузить в хранилище Хабра;
    • заменить attrs.src на URL, который вернул Хабр (снять attachmentId).
  3. Внешние http(s)-картинки (не sandbox) — на усмотрение habr.
  4. Запостить черновик.

Тело документа и картинок ходит RAM gitmost → HTTP → habr, мимо контекста модели.

Безопасность

  • Капабилити = непрогнозируемый UUID + короткий TTL + TLS. Секретов не хранить и не раздавать.
  • Анти-traversal: :id проверяется UUID-регуляркой, не используется как путь.
  • Целостность: ETag = sha256 тела (сильный валидатор) — habr ловит обрезанный/битый блоб. Это integrity, не авторизация.
  • RAM-guard: каппы на blob/картинку/суммарно + sweep по TTL → стор не растёт бесконечно.
  • Тела не логировать.
  • SVG из sandbox отдавать с image/svg+xml; exec-риск на стороне Хабра (он ре-хостит).

Краевые случаи

  • Блоб протух между stash_page и GET → 404; агент пере-stash'ит. В описании тула: «потребление в пределах TTL и одного аптайма».
  • Рестарт gitmost = стор пуст (ок, ephemeral) — тоже в описание тула.
  • Картинка > SANDBOX_MAX_IMAGE_BYTES → не зеркалится, в failed, исходный src остаётся (видно из счётчика).
  • Одна картинка дважды → один sandbox-blob, оба src на него (дедуп).
  • Переполнение SANDBOX_MAX_TOTAL_BYTES → внятная ошибка/частичный результат с предупреждением, не молчком.
  • Внешний src (CDN) не трогаем (regression-тест).
  • SANDBOX_PUBLIC_URL не задан, а фича включена → падать с понятной ошибкой на старте.
  • Несуществующий/не-UUID :id (в т.ч. ../) → 404/400, до стора не доходит.

Файлы

Новые:

  • apps/server/src/integrations/sandbox/sandbox.store.ts
  • apps/server/src/integrations/sandbox/sandbox.controller.ts
  • apps/server/src/integrations/sandbox/sandbox.module.ts
  • спеки: sandbox.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/mcpDocmostMcpConfig + поле 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.
  • детект внутреннего URL — 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)

  1. stash_page(pageId) возвращает {uri,size,sha256,images} (resource_link в MCP) — тело в контекст не попадает.
  2. GET /api/sb/:id отдаёт ровно те байты с верным ETag (= sha256); If-None-Match совпал → 304; после TTL → 404.
  3. Сквозной прогон документа: stash_page → внешний GET забирает документ целиком (сверка по ETag/sha256), мимо модели.
  4. Сквозной прогон картинок: статья с картинками → каждая тянется из sandbox анонимным GET с верными Content-Type/ETag → итоговый черновик с целыми картинками.
  5. Тесты: внутренний /api/files/... src переписан на sandbox-URL; внешний http-src не тронут; битая картинка → failed, документ не падает; дедуп; не-UUID :id → 404/400; TTL-протухание; каппы; ETag/If-None-Match→304; «тул вернул ссылку, а не тело».
  6. RAM не растёт неограниченно (sweep + каппы подтверждены тестом).
  7. lint + build + спеки зелёные; env задокументированы.

Вне скоупа (явно НЕ делаем)

  • Обратное направление (Habr → Docmost), POST /api/sb, create_page_from_blob.
  • Любые токены/секреты на эндпоинте (SANDBOX_TOKEN, X-Sandbox-Token).
  • Персистентность: диск, БД, объектное хранилище. Стор только в RAM, эфемерный.
## Контекст и проблема Агент внутри Docmost оркестрирует встроенные Docmost-тулы (in-app AI-SDK слой) и внешний **habr-mcp** (отдельный проект). Сейчас, чтобы передать тело документа от одного инструмента к другому, модель вынуждена выписывать его в аргумент вызова. На статье ~30 КБ ProseMirror это упирается в лимит и рвётся: ``` "Не удалось разобрать doc как JSON: Expecting ',' delimiter: line 1 column 27637" ``` Документ обрывается на ~27 КБ → битый JSON. Вдобавок чтение страницы через модель тоже обрезается (`_Truncated, omittedItems: 89`) — то есть «передать строку побольше» не лечит в принципе. **Картинки** ломаются отдельно. Image-нода у Docmost (см. `client.ts:2509-2516`): ```json { "type": "image", "attrs": { "src": "/api/files/<attachmentId>/<file>", "attachmentId": "<uuid>", "width": ... } } ``` `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. Ни диска, ни БД. - Капабилити = сам UUID-адрес + короткий TTL + TLS. **Никаких токенов.** Утёкший URL через час мёртв, угадать нереально. - Блоб иммутабелен (адресуется случайным id, содержимое под id не меняется) → его sha256 — идеальный сильный валидатор `ETag`. - Только **прямое** направление (Docmost → Habr). Обратки нет. - Картинки идут тем же каналом: gitmost зеркалит их в sandbox и переписывает `src`. ## Поток данных ``` agent context (видит только ссылку) ▲ │ uri resource_link/uri ▼ ┌─ gitmost (apps/server, NestJS+Fastify, один процесс) ──────────────┐ │ SandboxStore (RAM: Map<uuid,{buf,mime,sha256,expiresAt}>, TTL 1ч) │ │ ▲ put() локально ▲ get() локально │ │ │ │ │ │ stash_page tool GET /api/sb/:uuid (controller) │ │ (doc + миррор картинок) │ └──────────────────────────────────────┼─────────────────────────────┘ │ анонимный HTTP GET habr-mcp (отдельный проект) тянет doc + картинки, ре-хостит у себя ``` Один процесс gitmost = и хранилище (RAM), и раздача (HTTP). Тул пишет в стор in-process; читает стор только HTTP-контроллер. habr знает непрозрачные URL, gitmost наружу не ходит. --- ## Компоненты ### 1. `SandboxStore` — новый NestJS-провайдер Файл: `apps/server/src/integrations/sandbox/sandbox.store.ts`, singleton (`@Injectable`). ```ts import { randomUUID } from "node:crypto"; // already used in packages/mcp/src/http.ts // In-RAM, process-local blob store. No disk, no DB. Ephemeral by design. interface SandboxEntry { buf: Buffer; mime: string; sha256: string; expiresAt: number; } class SandboxStore { private readonly map = new Map<string, SandboxEntry>(); // id = randomUUID(); the unguessable id IS the read capability — no token. put(buf: Buffer, mime: string): { id: string; sha256: string; size: number; expiresAt: number }; get(id: string): SandboxEntry | undefined; // undefined if missing OR expired (lazy check) } ``` - `id` — `randomUUID()` (122 бита случайности). - `sha256` считается при `put`, хранится в записи и отдаётся как `ETag` — integrity-check, которым habr ловит обрезанный/битый блоб (исходный баг). - **TTL** = `SANDBOX_TTL_MS` (дефолт 1 ч): ленивая проверка в `get` + фоновый sweep. Скопировать паттерн `sweepTimer` + `.unref()` + `clearInterval` в `onModuleDestroy` из `mcp.service.ts:91-116` / `packages/mcp/src/http.ts:83-94`. - **RAM-guard:** каппы `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` (DI `SandboxStore`). Путь под общим API-префиксом. - валидировать `:id` UUID-регуляркой `^[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 наружу); - иначе **200**, body = сырые байты, заголовки `Content-Type: <mime>`, `Content-Length`, **`ETag: "<sha256>"`** — сильный валидатор (в кавычках, без `W/`), значением служит тот же sha256, что считается в `put`; - поддержать условный запрос: если `If-None-Match` совпадает с текущим `ETag` → **304 Not Modified** без тела (блоб иммутабелен, валидатор стабилен). - **Никаких токенов, никаких 401.** habr дёргает URL без авторизационных заголовков. 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 }`. Алгоритм: ``` 1. getPageRaw(pageId) -> content; сделать deep-clone для перезаписи. 2. Рекурсивно обойти все ноды (включая вложенные в callouts/tables) — переиспользовать walker из replaceImage (~client.ts:2812) / validateDocUrls (~client.ts:1207). Собрать ноды, у которых attrs.src — ВНУТРЕННИЙ файл-URL. 3. Детект внутреннего URL: normalizeFileUrl + isInternalFileUrl (префиксы /api/files/ и /files/, см. packages/editor-ext/src/lib/media-utils.ts и utils.spec.ts). Внешние http(s) src НЕ трогаем. 4. Дедуп по attachmentId/src. Для каждого уникального внутреннего src: - authed-фетч байтов с loopback: GET ${apiUrl}/files/<id>/<file> (переиспользовать auth DocmostClient + cap + 30s timeout + SSRF-границу, как в fetchRemoteImage ~client.ts:2408 / uploadImage ~client.ts:2563); - mime берём из Content-Type ответа; - SandboxStore.put(bytes, mime) -> uri; - в клоне node.attrs.src = uri (остальные attrs — width/align — сохранить; attachmentId можно оставить, Хабр его всё равно перезапишет). - параллелить с ограничением (4–6), таймаут на картинку. - фетч одной картинки упал -> НЕ валить документ: оставить исходный src, counter failed++. 5. SandboxStore.put(JSON.stringify(rewrittenContent), "application/json") -> doc uri. 6. Вернуть ссылку на документ + маленький счётчик по картинкам. ``` **Возврат — только ссылка, без тела** (иначе снова засорим контекст): - **MCP-слой** (`packages/mcp/src/index.ts`): content-item типа `resource_link` (НЕ embedded `resource`): ```ts return { content: [{ type: "resource_link", uri, name: "page.json", mimeType: "application/json", size }] }; ``` - **in-app слой** (`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_URL` | базовый URL для `uri`; **должен быт�� доступен habr-mcp по сети** (не loopback, если habr удалён) | — | | `SANDBOX_TTL_MS` | время жизни блоба | `3600000` | | `SANDBOX_MAX_BYTES` | кап на blob документа | `8388608` | | `SANDBOX_MAX_IMAGE_BYTES` | кап на картинку (ориентир — `MAX_IMAGE_BYTES` 20 MiB, `client.ts:2536`) | `20971520` | | `SANDBOX_MAX_TOTAL_BYTES` | суммарный кап стора (RAM-guard) | `134217728` | Токенов нет. ## Контракт для habr-mcp (другая команда / другой проект) 1. `GET {uri}` без авторизационных заголовков → тело документа; сверить целостность по `ETag` (= sha256 тела). Можно использовать `If-None-Match` для условных запросов. 2. Обойти ноды документа; для каждого `attrs.src`, указывающего на sandbox-URL: - `GET` байты картинки из sandbox (анонимно, верный `Content-Type` + `ETag`; сверить); - загрузить в хранилище Хабра; - заменить `attrs.src` на URL, который вернул Хабр (снять `attachmentId`). 3. Внешние `http(s)`-картинки (не sandbox) — на усмотрение habr. 4. Запостить черновик. Тело документа и картинок ходит `RAM gitmost → HTTP → habr`, мимо контекста модели. ## Безопасность - Капабилити = непрогнозируемый UUID + короткий TTL + TLS. Секретов не хранить и не раздавать. - Анти-traversal: `:id` проверяется UUID-регуляркой, не используется как путь. - Целостность: `ETag` = sha256 тела (сильный валидатор) — habr ловит обрезанный/битый блоб. Это integrity, не авторизация. - RAM-guard: каппы на blob/картинку/суммарно + sweep по TTL → стор не растёт бесконечно. - Тела не логировать. - SVG из sandbox отдавать с `image/svg+xml`; exec-риск на стороне Хабра (он ре-хостит). ## Краевые случаи - Блоб протух между `stash_page` и GET → 404; агент пере-stash'ит. В описании тула: «потребление в пределах TTL и одного аптайма». - Рестарт gitmost = стор пуст (ок, ephemeral) — тоже в описание тула. - Картинка > `SANDBOX_MAX_IMAGE_BYTES` → не зеркалится, в `failed`, исходный src остаётся (видно из счётчика). - Одна картинка дважды → один sandbox-blob, оба src на него (дедуп). - Переполнение `SANDBOX_MAX_TOTAL_BYTES` → внятная ошибка/частичный результат с предупреждением, не молчком. - Внешний `src` (CDN) не трогаем (regression-тест). - `SANDBOX_PUBLIC_URL` не задан, а фича включена → падать с понятной ошибкой на старте. - Несуществующий/не-UUID `:id` (в т.ч. `../`) → 404/400, до стора не доходит. ## Файлы **Новые:** - `apps/server/src/integrations/sandbox/sandbox.store.ts` - `apps/server/src/integrations/sandbox/sandbox.controller.ts` - `apps/server/src/integrations/sandbox/sandbox.module.ts` - спеки: `sandbox.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`. - детект внутреннего URL — `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) 1. `stash_page(pageId)` возвращает `{uri,size,sha256,images}` (resource_link в MCP) — **тело в контекст не попадает**. 2. `GET /api/sb/:id` отдаёт ровно те байты с верным `ETag` (= sha256); `If-None-Match` совпал → 304; после TTL → 404. 3. Сквозной прогон документа: `stash_page` → внешний `GET` забирает документ целиком (сверка по `ETag`/sha256), мимо модели. 4. Сквозной прогон картинок: статья с картинками → каждая тянется из sandbox анонимным GET с верными `Content-Type`/`ETag` → итоговый черновик с целыми картинками. 5. Тесты: внутренний `/api/files/...` src переписан на sandbox-URL; внешний `http`-src не тронут; битая картинка → `failed`, документ не падает; дедуп; не-UUID `:id` → 404/400; TTL-протухание; каппы; `ETag`/`If-None-Match`→304; «тул вернул ссылку, а не тело». 6. RAM не растёт неограниченно (sweep + каппы подтверждены тестом). 7. `lint` + `build` + спеки зелёные; env задокументированы. ## Вне скоупа (явно НЕ делаем) - Обратное направление (Habr → Docmost), `POST /api/sb`, `create_page_from_blob`. - Любые токены/секреты на эндпоинте (`SANDBOX_TOKEN`, `X-Sandbox-Token`). - Персистентность: диск, БД, объектное хранилище. Стор только в RAM, эфемерный.
Owner

Это делалось под https://github.com/vvzvlad/habr-mcp, после реализации сравни с контрактом и кодом, чтобы оно работало вместе. Внутри кода gitmost не должно быть ничего про хабр, это фича абстрактная, просто реализация передачи блобов.

Это делалось под https://github.com/vvzvlad/habr-mcp, после реализации сравни с контрактом и кодом, чтобы оно работало вместе. Внутри кода gitmost не должно быть ничего про хабр, это фича абстрактная, просто реализация передачи блобов.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#243