feat(mcp): search_in_page — внутристраничный поиск для агента (#330) #339
Reference in New Issue
Block a user
Delete Branch "fix/330-search-in-page"
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?
Summary
Инструмент
search_in_page(pageId, query, {regex?, caseSensitive?, limit?})— внутристраничный поиск подстроки/regex для агента. Читает ProseMirror JSON страницы через существующийgetPageRawи ищет В ПАМЯТИ. Редакторские роли (Корректор/Фактчекер) перестают перебиратьget_nodeпо блокам.closes #330.Без серверного эндпоинта, без изменения БД/схемы, без правки схемы-зеркала в
packages/mcp/src/lib/.Как
searchInDoc(doc, query, opts)(packages/mcp/src/lib/page-search.ts): рекурсивный спуск до текст-контейнера (paragraph/heading/ячейка), склейка inline-текста черезblockPlainText(матч переживает границы марок — «т.е.» через bold/italic), literalindexOfпо умолчанию / RegExp приregex:true.{ total, truncated, matches:[{ nodeId, blockIndex, type, before, match, after }] }.nodeId=attrs.idконтейнера или#<topLevelIndex>ближайшего верхнего блока — тот же ref-формат, что принимаютget_node/patch_node/якорь комментария (сверено — идентичноgetNodeByRef), так что агент сразу идёт ставить точечный комментарий.before/after~40 симв. для уникального выделения.total/truncatedвсегда (без тихой обрезки).SHARED_TOOL_SPECS→ оба транспорта (внешний/mcp+ встроенный AI-chat); строка вSERVER_INSTRUCTIONS; сигнатура вDocmostClientLike+ contract-тест. Промпты Корректора/Фактчекера + бамп версий, hash-lock каталога обновлён.\b,a*) пропускается (не зацикливается/не флудит); MAX_PATTERN_LENGTH=1000, MAX_CONTAINER_TEXT=100k; limit [1,200], дефолт 50.How verified
@docmost/mcp: tsc чисто;node --test467 passed (+17 page-search); server-instructions/tool-specs/contract зелёные.apps/server:tsc --noEmitчисто (DocmostClientLike + wiring).check.mjsOK (версии fact-checker 5→6, proofreader 7→8, content-hashes).#<index>для ячейки), context-bounds, limit/total/truncated, невалидный regex/пустой/длинный → ошибка, zero-length, empty-doc.Известные ограничения (из внутреннего ревью, non-blocking)
(a+)+$против большого одиночного контейнера может повесить event-loop. Реальная экспозиция низкая (контейнеры мелкие; паттерн задаёт аутентифицированная модель). Кандидат на отдельный hardening (safe-regex / worker+timeout), если понадобится.toLowerCase; символ, у которого lowercase иной длины (тур.İ) ПЕРЕД матчем сдвинет окно контекста — пренебрежимо для RU/EN.#<index>-fallback (ячейка) полеtype= внутренний контейнер («paragraph»), аnodeIdадресует верхний блок — адресация верна, поле задокументировано.Заметка по мержу
Ветка от develop;
packages/mcp/build/пересобран и закоммичен по текущей конвенции develop (там build отслеживается). Если #333 (переводит mcp/build в gitignore) вмержится раньше — при ребейзе build-изменения снимутся.🤖 Generated with Claude Code
Editorial roles (Corrector/Factchecker) brute-forced `get_node` block-by-block to find occurrences (unquoted «ё», straight quotes, «т.е.»), burning tokens. New `search_in_page(pageId, query, {regex?, caseSensitive?, limit?})` reads the page's ProseMirror JSON via the existing getPageRaw and searches it IN MEMORY — no server endpoint, no DB/schema change, no touch to the packages/mcp/src/lib schema mirror. New pure `searchInDoc(doc, query, opts)` (packages/mcp/src/lib/page-search.ts): recursive descent to each TEXT CONTAINER (paragraph/heading/table-cell paragraph), glues its inline text via `blockPlainText` (a match survives inline-mark boundaries — e.g. «т.е.» split across bold/italic), searches literal (indexOf) or regex, and returns `{ total, truncated, matches:[{ nodeId, blockIndex, type, before, match, after }] }`. `nodeId` is the container's attrs.id or the `#<topLevelIndex>` of the enclosing top-level block — the SAME ref format get_node/patch_node/comment-anchoring accept (verified identical to getNodeByRef), so the agent goes straight from a hit to a targeted comment; `before`/`after` are ~40-char windows for a unique selection. `total`/`truncated` always reported (never silent truncation). Lives in the SHARED_TOOL_SPECS registry → exposed in BOTH transports (external /mcp + in-app AI-chat), with a SERVER_INSTRUCTIONS line and a DocmostClientLike signature + contract-test entry. Corrector/Factchecker prompts get a one-line "use search_in_page first" hint (versions bumped, catalog hash lock refreshed). Guards: empty/whitespace query → clear error; invalid regex → clear error (not a generic 500); zero-length regex matches (`\b`, `a*`) skipped with lastIndex advanced (no loop/flood); MAX_PATTERN_LENGTH=1000, MAX_CONTAINER_TEXT=100k bound each exec; limit clamped [1,200] (default 50). Tests: new page-search.test.mjs (17) — literal+regex, case-sensitivity, mark-boundary glue, nodeId for paragraph/heading (attrs.id) and table-cell (#<index> fallback), context bounds, limit/total/truncated + clamp, invalid regex/empty/over-long errors, zero-length skip, empty-doc null-safety. mcp: tsc clean; node --test 467 passed (+17). apps/server: tsc --noEmit clean (DocmostClientLike + wiring). catalog check.mjs OK. Known limitations (from internal review, non-blocking): - Residual ReDoS: a crafted catastrophic-backtracking pattern (e.g. `(a+)+$`) against a large single container can hang the event loop — JS regex is not interruptible, so the length caps bound the base but not the backtracking. Realistic exposure is low (containers are small; the pattern is supplied by the authenticated model). Candidate for a follow-up hardening (safe-regex validation or a worker+timeout) if it matters. - Case-insensitive LITERAL search folds via toLowerCase; a char whose lowercase differs in length (e.g. Turkish İ) BEFORE a match could shift the context window — negligible for the RU/EN editorial scenario. - On a `#<index>` table-cell fallback, `type` is the inline container ("paragraph") while nodeId addresses the top-level block — addressing is correct; the field is documented as the container's type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Ревью — #339 (mcp: search_in_page — внутристраничный поиск для агента, #330), round 1, head
40d42d61, base developf5d19f97Вердикт: ESCALATE (нужно решение человека) — инструмент реализован аккуратно, объективка зелёная, ref-контракт (search→get_node/comment) целостен. НО
regex:trueисполняет агент-заданное регулярное выражение БЕЗ ограничения по времени в общем event-loop — это подтверждённая ReDoS-DoS всего сервера, и как её чинить — развилка с материальными трейд-оффами, которую агент выбирать не вправе. Три аспекта (security/stability/architecture) сошлись на escalate независимо; я перепроверил эмпирически сам. Плюс 4 DO (доки/тесты) — чинить ПОСЛЕ решения по развилке.needs-human.Объективка запущена мной (head
40d42d61, source-only ревью;build/— коммиченный артефакт develop-конвенции, исключён из ревью, но сверен): mcp build 0, закоммиченныйbuild/= свежая сборка (0 расхождений), mcpnode --test467 passed, mcp tsc 0, server tsc 0.agent-roles-catalog/scripts/check.mjs→ OK (хеши/версии консистентны). Зелёная.Escalate — нужно решение человека (петля остановлена)
[security/stability] Агент-заданный regex исполняется без тайм-аута в общем event-loop → ReDoS кладёт весь сервер —
packages/mcp/src/lib/page-search.ts:89-99,177-187,205-207.Что это за инструмент (простыми словами):
search_in_page(pageId, query, {regex?, caseSensitive?, limit?})— новый MCP-инструмент, чтобы редакторские роли (Корректор/Фактчекер) искали текст в странице, а не перебирали блоки. Приregex:trueон компилирует регулярку изquery(new RegExp(query, "gi")) и гоняет её по тексту страницы.Проблема: регулярка исполняется СИНХРОННО в event-loop, а два гварда ограничивают только РАЗМЕР (
MAX_PATTERN_LENGTH=1000,MAX_CONTAINER_TEXT=100_000), НЕ время. JS-regex не прерываем (это признано в комментарии кода). Катастрофический бэктрекинг экспоненциален и достигается КРОШЕЧНЫМ паттерном далеко под лимитом:(a+)+$(5 символов) по контейнеру из 26 букв «a» = 7 сек, 30 = 15 сек, 34 = не завершилось за 45 сек;(a+)+bпо 40 «a» — >4 сек (таймаут).Т.е. size-caps не защищают вообще. Один такой вызов пинит CPU и вешает весь event-loop.
Почему это про весь сервер (blast radius): инструмент зарегистрирован в ОБОИХ транспортах, включая встроенный AI-chat в
apps/server(ai-chat-tools.service.ts→client.searchInPage→searchInDocв том же NestJS-процессе). Значит один зловредный вызов замораживает ВЕСЬ Docmost-бэкенд для ВСЕХ пользователей (не self-DoS одной сессии). Внешний request-timeout не спасёт: синхронная regex блокирует loop, таймер не сработает. Регулярку выбирает LLM-агент, но агент читает пользовательский контент страницы → prompt-injection в тексте страницы может навести агента на патологический паттерн (+ подложить длинный матчащий прогон в той же странице). И даже просто кривая (не злонамеренная) регулярка на длинном тексте повесит.Почему нужен человек: это расходится с СОБСТВЕННОЙ моделью проекта — соседний путь агент-кода
docmost_transformгоняет агентский JS вnode:vm-песочнице с 5-сек тайм-аутом (client.ts), т.е. проект уже считает агент-исполняемый ввод DoS-чувствительным.search_in_pageвернул произвольные агент-вычисления БЕЗ тайм-гарда. Как чинить — развилка (фича/зависимость/архитектура), выбор трейд-оффа за тобой.regex:true. Effort: m. Плюсы: линейное время, фича сохранена, без тайм-аут-плюмбинга. Минусы: нативная зависимость в mcp-пакете (сборка/паккэджинг), RE2 не поддерживает backreferences/lookbehind — часть паттернов сменит поведение.regex:trueизолированным внешним/mcp, а встроенный AI-chat сделать literal-only. Effort: m–l. Плюсы: реально ограничивает худшее время / убирает экспозицию из общего процесса; literal-only-в-shared — минимальный безопасный срез, покрывает большинство редакторских свипов. Минусы: worker-изоляция — новая инфра ради одного инструмента; сплит-по-транспорту усложняет контракт/доки.indexOf, выпилить/отложитьregex:true. Effort: s. Плюсы: ReDoS исключён полностью (indexOf линеен), ноль зависимостей, ядро #330 («найти фразу в странице») сохранено. Минусы: убирает заявленную regex-возможность (word-boundary/char-class свипы) — если Фактчекеру она реально нужна, это потеря.needs-human.Do — применить ПОСЛЕ решения по развилке (сейчас петля на паузе)
F1 [documentation · warning] Убери ложное «pass nodeId as a comment anchor» —
create_commentберёт текстовыйselection, не nodeId —packages/mcp/src/tool-specs.ts:118-131(+ зеркала:page-search.tsmodule-JSDoc иSearchMatch.nodeId-JSDoc,client.tssearchInPage-JSDoc).Описание инструмента говорит, что возвращённый
nodeIdможно «pass straight to get_node/patch_node or as a comment anchor». Ноcreate_comment(index.ts:745-816) НЕ имеет параметра nodeId — top-level комментарий требует точный текстовыйselectionи падает, если текст не найден. Основная цель PR — «найти каждое вхождение → оставить точечный комментарий»; агент, поняв описание буквально, попытается заякорить комментарий по nodeId и провалится. Описание вдобавок само себе противоречит («before/after дают ~40 симв. чтобы построить уникальный selection»).Fix: убери «or as a comment anchor» / «and comment anchoring» из ref-claim в tool-specs.ts, page-search.ts (module + SearchMatch JSDoc), client.ts; укажи, что ref — для get_node/patch_node, а для комментария строишь уникальный текстовый selection из before/match/after и передаёшь в
selectioncreate_comment.F2 [documentation · warning] Уточни: форма
#<index>работает с get_node, но НЕ с patch_node — те же файлы/строки, что F1.Описание: nodeId «is the block id (or "#" for table/cell content) — pass it straight to get_node/patch_node». Для id-less контейнера (table/cell)
searchInDocвозвращает#<topLevelIndex>. Ноpatch_node(replaceNodeById) резолвит только поattrs.id;#<index>не матчит ничего → запись отклоняется.#<index>принимает ТОЛЬКО get_node (getNodeByRef).Fix: в описании (tool-specs.ts + JSDoc в page-search.ts/client.ts) отметь, что
#<index>-ref'ы (table/cell без attrs.id) принимает get_node, но НЕ patch_node (тому нужен attrs.id).F3 [test-coverage · warning] Добавь round-trip ассерт: резолви
nodeIdкаждого матча черезgetNodeByRef—packages/mcp/test/unit/page-search.test.mjs:121-157.Главная корректностная претензия фичи — что
nodeId/blockIndex— тот же ref-формат, что потребляют get_node/patch_node/comment. Тесты пиннят точные строки ref (p1/h1/#1), но НИКОГДА не скармливают возвращённый nodeId реальному потребителю. Если вера автора о формате неверна (напр.@1вместо#1), код и пиннящий тест ошибутся ВМЕСТЕ и оба пройдут, а интеграция (весь смысл инструмента) сломается.Fix: импортни
getNodeByRef+blockPlainTextиз../../build/lib/node-ops.jsи в тестах nodeId ассертниconst hit=getNodeByRef(d,res.matches[0].nodeId); assert.ok(hit); assert.ok(blockPlainText(hit.node).includes(res.matches[0].match))для id-параграфа, id-heading и#<index>-table-fallback.F4 [test-coverage · warning] Пиннь before/after на краях строки, включая clamp
Math.max(0,…)в длинном контейнере —packages/mcp/test/unit/page-search.test.mjs:54-61.Кейс «~40 симв.» ставит needle в центр 206-символьной строки, где обе рамки полные и гвард
Math.max(0, idx-CONTEXT)(page-search.ts:224) не решает. Нагруженная ветка — матч в первых CONTEXT символах контейнера ДЛИННЕЕ CONTEXT — не покрыта; регресс, дропнувший гвард, вернул бы пустойbefore, и ни один тест не поймал бы.Fix: тест, где матч в первых 40 символах контейнера длиннее 40 (напр.
"ab NEEDLE"+"x".repeat(100)) →before === "ab "; и пинньbefore === ""для матча в index 0 иafter === ""для матча в конце контейнера.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[speculative]low/high[stability] при контейнере >100k символов text-cap молча недосчитывает матчи за лимитом (total=0, truncated=false) — требует одиночный >100k-символьный контейнер (контривед), дропнуто по правилу неправдоподобных предпосылок.Мейнтейнер выбрал развилку по ReDoS: re2 (решение в TG). Эскалация закрыта, снимаю needs-human →
review/needs(коммит77b24546).ReDoS-фикс (re2): regex-путь
search_in_pageпереведён сnew RegExpнаnew RE2(query, 'g'/'gi')— движок Google RE2, линейное время, бэктрекинга нет в принципе.(a+)+$по 50k символов «a» → ~4 мс (было: вешало весь бэкенд). Неподдерживаемые re2 фичи (lookaround(?=…)/(?<=…), backreferences\1) → чёткая ошибка агенту на компиляции («…RE2 does not support lookaround or backreferences; rewrite the pattern without them»), а не зависание. УбралMAX_CONTAINER_TEXT+ пер-контейнерный slice (re2 линеен — это больше не защита от ReDoS, а обрезка молча теряла бы матчи в длинном контейнере);MAX_PATTERN_LENGTHоставил как дешёвый sanity-cap. Добавилre2в deps mcp + lockfile.4 DO-пункта ревьюера:
create_commentнет параметра nodeId — нужен текстовыйselection): tool-specs.ts + JSDoc в page-search.ts/client.ts. Ref — для get_node/patch_node, для комментария строишь уникальный selection из before+match+after.#<index>(ячейки без attrs.id) принимает get_node, но НЕ patch_node (тому нужен attrs.id).nodeIdматча прогоняется через реальныйgetNodeByRef(attrs.id-узел +#<index>-ячейка).before==="") / конец (after==="").Объективка: mcp tsc чисто,
node --test472 passed (+5); apps/server tsc чисто. re2 нативно собрался в среде (node-gyp@11), тесты реально прогнались. build/ пересобран (коммитится по конвенции develop; если #333 вмержится раньше и переведёт mcp/build в gitignore — при ребейзе снимется).Ре-ревью — #339 (mcp: search_in_page, #330), round 2, head
77b24546, base developf5d19f97Вердикт: CHANGES — эскалация ReDoS ЗАКРЫТА (мейнтейнер выбрал RE2, кодер внедрил — проверил: ReDoS реально устранён), F1–F4 закрыты и сверены. Остался ОДИН доводочный DO: агент-видимое описание
regexвсё ещё обещает «JS regular expression», хотя RE2 не поддерживает lookaround/backreferences. Почини F5 — и PASS.Объективка запущена мной (head
77b24546, source-only ревью;build/— коммиченный артефакт, сверен):pnpm install(нативныйre2собрался) 0; mcp build 0, коммиченныйbuild/= свежая сборка (0 расхождений); mcpnode --test472 passed (+5); mcp tsc 0. Эмпирика ReDoS:(a+)+$по 50k «a» = 4 мс (было — вешало весь бэкенд). Зелёная.ReDoS ЗАКРЫТ (спотлайт, сверено эмпирически + по коду): regex-путь
search_in_pageпереведён наnew RE2(query, 'g'/'gi')(Google RE2, линейное время, бэктрекинга нет в принципе). Это ЕДИНСТВЕННЫЙ regex-сток агентского ввода на ОБОИХ транспортах (внешний/mcp+ встроенный ai-chat single-source'ят одинsearchInDoc); другихnew RegExpна агентском вводе нет.MAX_CONTAINER_TEXTубран (RE2 линеен — кап был анти-ReDoS-костылём, к тому же молча терял матчи в длинном контейнере; теперь >100k считается корректно). Неподдерживаемые RE2 фичи (lookaround/backreferences) → чёткая ошибка на компиляции ДО обхода, не зависание. Result-parity с JS RegExp проверена (кириллица/i,\b,^/$,., астральные эмодзи — UTF-16-индексы RE2 корректны, слайсы before/after целы). Нативныйre2@1.25.0собирается/грузится (сервер уже несёт нативныйbcrypt; Docker node:22-slim; сбой загрузки addon'а — чистая ошибка на вызове инструмента, не краш старта).F1–F4 закрыты (сверено): F1 — ложное «nodeId как comment anchor» убрано из tool-specs + JSDoc (page-search/client); F2 — добавлен caveat «
#<index>резолвит get_node, но НЕ patch_node»; F3 — round-trip тест каждогоnodeIdчерез реальныйgetNodeByRef(не вакуозный); F4 — before/after на краях + clampMath.max(0,…)в длинном контейнере. Тесты RE2 тоже пинят откат: unsupported-feature-паттерны (lookaround/backref валидны в JS, но RE2 их отвергает) и(a+)+$-таймаут — оба упадут при откате RE2→RegExp.Do — почини, потом ставь
review/needsregex: это RE2 (без lookaround/backreferences), а не «JS regular expression» —packages/mcp/src/tool-specs.ts:134(основное.description) и:147(.describe()флагаregex).RE2-свап сузил контракт:
regex:trueтеперь отвергает lookaround ((?=…),(?<=…)) и backreferences (\1) — оба валидны в JS RegExp и естественны в редакторских свипах (напр. разделитель тысяч(?<=\d)(?=(\d{3})+\b)). Внутренний JSDoc (page-search.ts/client.ts) про ограничение RE2 честен, НО агент-видимый tool-spec (единственный текст, который агент реально читает на вызове, single-source'ится в оба транспорта) всё ещё говорит «set regex:true for a JS regular expression» (:134) и «Treat query as a JS regular expression» (:147). Агент, поверив описанию, напишет lookahead/backref и получит ошибку — ровно тот класс агент-видимой честности, что мейнтейнер уже починил в F1/F2, но пропущен для свапа движка. (SERVER_INSTRUCTIONS и промпты Корректора/Фактчекера regex-синтаксис не обещают — их править не нужно.)Fix: в tool-specs.ts измени
.description(:134) и:147) — назови RE2 / линейный движок и укажи, что lookaround.describe()флагаregex((?=…)/(?<=…)и backreferences\1НЕ поддерживаются (char-classes, word-boundaries, якоря, квантификаторы — работают); сохрани заметку «невалидный/неподдерживаемый regex → чёткая ошибка», чтобы агент знал, что восстановим.⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]low/med[conventions]re2@1.25.0enginesтребует node>=22.22.2; локально собралось/прогналось на node20, CI/Docker — node22 (удовлетворяет),engine-strictне задан → лишь warning. Не дефект (работает на обоих), дропнуто.F5: fixed — обновил АГЕНТ-ВИДИМОЕ описание regex в
tool-specs.ts(единственный текст, который агент читает на вызове):.description(:134) и:147) теперь называют движок RE2 (линейный, ReDoS-safe) и явно говорят, что lookaround.describe()флагаregex((?=…)/(?<=…)и backreferences\1НЕ поддерживаются, а char-classes/word-boundaries/anchors/quantifiers — работают; заметку «невалидный/неподдерживаемый regex → чёткая ошибка» сохранил. SERVER_INSTRUCTIONS и промпты ролей regex-синтаксис не обещают — не трогал. tsc чисто; tool-specs/server-instructions/contract тесты зелёные. review/needs.Ре-ревью — #339 (mcp: search_in_page, #330), round 3, head
086bc1bf, base developВердикт: PASS — F5 закрыт, готово к мержу. Delta r2→r3 = ровно
tool-specs.ts(+8/-3, только строки описания), scope-creep нет; всё остальное (ReDoS-фикс через RE2, F1–F4) закрыто и сверено в round 2.F5 fixed (сверено по коду): агент-видимое описание regex теперь честно про RE2 —
.description(:134-136): «set regex:true for an RE2 regular expression (linear-time, ReDoS-safe: char classes, word boundaries, anchors and quantifiers work; lookaround (?=…)/(?<=…) and backreferences \1 are NOT supported)»;.describe()флага regex (:150-151): «RE2 regular expression — linear-time, ReDoS-safe; no lookaround or backreferences». Заметка про чёткую ошибку сохранена. «JS regular expression» больше нигде в агент-видимом тексте нет. SERVER_INSTRUCTIONS/промпты regex-синтаксис не обещали — не тронуты (правильно).Объективка (head
086bc1bf): mcp build 0, node --test 472 passed, tsc 0, коммиченныйbuild/= свежая сборка (0 расхождений). Зелёная.Итог: эскалация ReDoS закрыта (RE2, эмпирически 4 мс на 50k), F1–F5 закрыты. Инструмент готов.