[feat][ui][provenance] API-ключи фаза 3C: персона «MCP внешний» для правок и комментов внешних агентов #559

Open
opened 2026-07-12 17:53:08 +03:00 by agent_vscode · 0 comments
Collaborator

Фаза C мета-ишьи #556. Blocked by #501 (merged) и #506.

Проблема / Цель

Правки и комментарии, сделанные внешним MCP-агентом (аутентификация по api_key), сейчас атрибутируются обычному человеку — как будто их сделал он сам. Нужно показывать их отдельной персоной «MCP внешний» в истории правок страницы и в комментариях — по аналогии с тем, как встроенные агенты показывают имя своей роли. Аналог «имени роли» для внешнего агента — имя api_key (человек называет ключ под агента, напр. agent-node-2).

Контекст и исследование

Инфраструктура персон агентов уже сквозная — не хватает одного звена:

  • Резолвер resolveAgentProvenance (agent-provenance.ts:62-93) уже имеет внешнюю-MCP ветку (aiChatId == null → показывает creator.name) и внутреннюю (aiChatId != nullagentRole.name + launcher). Клиент уже ветвится на source === 'agent': history-item.tsx:73,144-153, comment-list-item.tsx:173-202; AgentAvatarStack (agent-avatar-stack.tsx) провенанс-агностичен — рендерит что дал сервер.
  • Недостающее звено: resolveProvenance метит actor='agent' только при user.isAgent (auth-provenance.decorator.ts:30-38). Плоско-человеческий api_key → actor='user' → персона не срабатывает.
  • ProvenanceSource = 'user' | 'agent' (jwt-payload.ts:7); дискриминатор внешнего MCP на collab-слое уже есть: CollabPrincipal='api_key' + apiKeyId в JwtCollabPayload.
  • *Source-колонки: comments.createdSource/aiChatId, pages.lastUpdatedSource/lastUpdatedAiChatId, pageHistory.lastUpdatedSource/lastUpdatedAiChatId (миграция 20260616T130000-agent-provenance.ts). Запись: agentSourceFields(provenance,...) в comment.service.ts:151, page.service.ts:198,291,.... Резолв имени роли — join к aiAgentRoles без фильтра deleted (историческая подпись переживает удаление роли): page-history.repo.ts:26-34, comment.repo.ts:26-32.

Дизайн

Модель трёх «форм» атрибуции (закрепить явно): human (source='user'), внутренний агент (source='agent', aiChatId != null, api_key_id == null → роль + launcher), внешний MCP (source='agent', aiChatId == null, api_key_id != null → «MCP внешний / <имя ключа>»). Переиспользуем source='agent' (не вводим новый ProvenanceSource) — клиент уже срабатывает.

1. Метить api_key-правки как агентские + протащить apiKeyId ДО МЕСТА ЗАПИСИ

Ключевая находка адверсариального ревью: на collab/page-пути apiKeyId теряется между mint токена и записью в БД, поэтому наивная реализация запишет api_key_id = null для правок страниц (самый частый случай) и персона всегда будет падать в фолбэк. Протащить сквозняком, зеркаля aiChatId:

  • REST-путь (комменты, page create/rename/move): в ApiKeyService.validate дополнительно вернуть строку/name (сейчас грузит row, но отдаёт только {user,workspace}api-key.service.ts:163); в jwt.strategy.validateApiKey (:102-129) застампить req.raw.apiKeyId (уже есть) и пометить провенанс как внешне-MCP. resolveProvenance/agentSourceFields (auth-provenance.decorator.ts:30-68) расширить: при наличии apiKeyIdsource='agent' + прокинуть api_key_id в набор полей записи.
  • Collab-путь (контент страниц): authentication.extension.doAuthenticate (:169-180) сейчас возвращает контекст {user, actor, aiChatId}добавить apiKeyId (из jwtPayload.apiKeyId, уже верифицирован для revocation-recheck на :85-96). Далее persistence.extension (:436-449, где пишется lastUpdatedAiChatId) — писать lastUpdatedApiKeyId: context?.apiKeyId ?? null; и page-history.repo.saveHistory (:108-111) — копировать lastUpdatedApiKeyId в снапшот. Также пометить resolveSource/actor='agent' для principal='api_key'.
  • Явно определить форму source='agent' && aiChatId==null && api_key_id==null (напр. is_agent-аккаунт по обычному ACCESS-токену): решение — оставить существующую внешнюю ветку резолвера (фолбэк-имя), НЕ ломать. Задокументировать маппинг всех трёх форм в resolveAgentProvenance.
  • Реклассификация личного ключа человека: любой api_key-write (в т.ч. личный ключ человека в скрипте) станет «MCP внешний». Это осознанно и честно (доступ действительно программный, через api_key, а не интерактивный) — зафиксировать в критериях.

2. Персист новых колонок (миграция, аддитивная)

  • comments.created_api_key_id (nullable), pages.last_updated_api_key_id (nullable), page_history.last_updated_api_key_id (nullable).
  • FK → api_keys.id .onDelete('set null') (критично, адверсариальный ревью): наивное копирование api_keys.creator_id onDelete cascade дало бы каскадное удаление атрибутированных страниц/комментов при удалении пользователя — категорически нет. set null → при удалении → фолбэк-имя.
  • Миграция без бэкофилла (nullable) → без блокировок горячих таблиц.

3. Резолв имени персоны

resolveAgentProvenance внешняя ветка: имя = join api_keys.name по *_api_key_id (без фильтра deletedAt → переживает revoke, как роль переживает удаление) с фолбэком «MCP внешний» (константа рядом с AGENT_FALLBACK_NAME='AI agent'). Добавить apiKeyRoleQuery-аналог в page-history.repo и comment.repo (зеркало aiAgentRoles-join). AgentProvenanceInput расширить полем api_key_id/apiKeyName.

  • Rename ключа: история покажет текущее имя (join к живой строке) — консистентно с тем, как роль-join показывает текущее имя роли. Приемлемо.
  • Hard-delete ключа (удалён владелец/воркспейс, api_keys каскадит): *_api_key_id → null (set null) → фолбэк «MCP внешний». Историческая атрибуция вырождается в тип без имени — by design.

4. Клиент

Персона считается сервером — AgentAvatarStack менять не надо. Минимум: убедиться, что source==='agent' уже покрывает внешний MCP (да, history-item.tsx:73, comment-list-item.tsx:173); опционально добавить в AgentAvatarStack tooltip-вариант «MCP внешний» (тип персоны) поверх имени ключа. Типы lastUpdatedSource/createdSource уже string — расширения не требуют.

Границы scope

  • Только атрибуция/персона api_key-правок. Авторизация — #557/#558.
  • Не входит: персона для stdio-CLI; отдельный ProvenanceSource='external_mcp' (переиспользуем 'agent').

Критерии приёмки

  1. Правка страницы внешним MCP-агентом (api_key, collab-путь) → в истории показывается персона «MCP внешний» с именем ключа (agent-node-2), НЕ имя человека; page_history.last_updated_api_key_id заполнен (не null — регресс-защита от потери apiKeyId на collab-пути).
  2. Коммент внешним MCP-агентом (api_key, REST) → автор показан как «MCP внешний / <имя ключа>»; comments.created_api_key_id заполнен.
  3. Встроенный AI-агент (внутренний, aiChatId != null) по-прежнему показывает имя роли + launcher — не задет.
  4. Обычная правка человеком (source='user') → плоский человеческий аватар, без персоны.
  5. Правки ключа, который затем отозван (soft-delete), сохраняют его имя в истории (join без фильтра deletedAt).
  6. Удаление колонок-владельца (api_keys hard-delete через каскад) → правка не удаляется, персона вырождается в «MCP внешний» без имени (FK set null).
  7. Личный api_key человека → отображается как «MCP внешний» (осознанная реклассификация).

Отказы и наблюдаемость

  • Потеря имени ключа (hard-delete / null) → детерминированный фолбэк «MCP внешний», не пустая/сломанная строка.
  • Никаких новых отказных путей авторизации — только атрибуция.

Совместимость и миграции

  • Аддитивная миграция: 3 nullable-колонки + FK onDelete('set null'). Обратима (drop columns). Без блокировок (нет бэкофилла).
  • Изменение поведения: api_key-правки, ранее выглядевшие как человеческие, теперь показываются персоной «MCP внешний» — целевое изменение.

Решённые развилки

  • Переиспользовать source='agent' + дискриминатор api_key_id, не вводить новый ProvenanceSource (внешняя ветка резолвера и клиентское ветвление уже есть).
  • Имя персоны = имя api_key через join (не снапшот строки) — консистентно с роль-join; переживает revoke, вырождается при hard-delete владельца.
  • FK новых колонок = onDelete('set null'), НЕ cascade (иначе удаление юзера сносит контент — адверсариальный ревью).
  • apiKeyId тащится до persistence.extension+saveHistory, не только до mint (иначе page-правки всегда без имени — адверсариальный ревью).

Связи

  • Мета: #556. Blocked by #501 (merged), #506. Независима от #557/#558 технически (api_key REST-путь есть с #501), но часть фазы 3 — приступать после мержа #506.
Фаза C мета-ишьи #556. **Blocked by #501 (merged) и #506.** ## Проблема / Цель Правки и комментарии, сделанные **внешним MCP-агентом** (аутентификация по api_key), сейчас атрибутируются обычному человеку — как будто их сделал он сам. Нужно показывать их **отдельной персоной «MCP внешний»** в истории правок страницы и в комментариях — по аналогии с тем, как встроенные агенты показывают имя своей роли. Аналог «имени роли» для внешнего агента — **имя api_key** (человек называет ключ под агента, напр. `agent-node-2`). ## Контекст и исследование Инфраструктура персон агентов **уже сквозная** — не хватает одного звена: - Резолвер `resolveAgentProvenance` ([agent-provenance.ts:62-93](apps/server/src/database/repos/agent-provenance.ts#L62-L93)) уже имеет **внешнюю-MCP ветку** (`aiChatId == null` → показывает `creator.name`) и внутреннюю (`aiChatId != null` → `agentRole.name` + launcher). Клиент уже ветвится на `source === 'agent'`: [history-item.tsx:73,144-153](apps/client/src/features/page-history/components/history-item.tsx#L73), [comment-list-item.tsx:173-202](apps/client/src/features/comment/components/comment-list-item.tsx#L173-L202); `AgentAvatarStack` ([agent-avatar-stack.tsx](apps/client/src/components/ui/agent-avatar-stack.tsx)) провенанс-агностичен — рендерит что дал сервер. - **Недостающее звено**: `resolveProvenance` метит `actor='agent'` только при `user.isAgent` ([auth-provenance.decorator.ts:30-38](apps/server/src/common/decorators/auth-provenance.decorator.ts#L30-L38)). Плоско-человеческий api_key → `actor='user'` → персона не срабатывает. - **`ProvenanceSource = 'user' | 'agent'`** ([jwt-payload.ts:7](apps/server/src/core/auth/dto/jwt-payload.ts#L7)); дискриминатор внешнего MCP на collab-слое уже есть: `CollabPrincipal='api_key'` + `apiKeyId` в `JwtCollabPayload`. - **`*Source`-колонки**: `comments.createdSource`/`aiChatId`, `pages.lastUpdatedSource`/`lastUpdatedAiChatId`, `pageHistory.lastUpdatedSource`/`lastUpdatedAiChatId` (миграция `20260616T130000-agent-provenance.ts`). Запись: `agentSourceFields(provenance,...)` в [comment.service.ts:151](apps/server/src/core/comment/comment.service.ts#L151), [page.service.ts:198,291,...](apps/server/src/core/page/services/page.service.ts#L198). Резолв имени роли — join к `aiAgentRoles` без фильтра `deleted` (историческая подпись переживает удаление роли): [page-history.repo.ts:26-34](apps/server/src/database/repos/page/page-history.repo.ts#L26-L34), [comment.repo.ts:26-32](apps/server/src/database/repos/comment/comment.repo.ts#L26-L32). ## Дизайн Модель трёх «форм» атрибуции (закрепить явно): **human** (`source='user'`), **внутренний агент** (`source='agent'`, `aiChatId != null`, `api_key_id == null` → роль + launcher), **внешний MCP** (`source='agent'`, `aiChatId == null`, `api_key_id != null` → «MCP внешний / <имя ключа>»). Переиспользуем `source='agent'` (не вводим новый `ProvenanceSource`) — клиент уже срабатывает. ### 1. Метить api_key-правки как агентские + протащить `apiKeyId` ДО МЕСТА ЗАПИСИ Ключевая находка адверсариального ревью: **на collab/page-пути `apiKeyId` теряется** между mint токена и записью в БД, поэтому наивная реализация запишет `api_key_id = null` для правок страниц (самый частый случай) и персона всегда будет падать в фолбэк. Протащить сквозняком, зеркаля `aiChatId`: - **REST-путь (комменты, page create/rename/move)**: в `ApiKeyService.validate` дополнительно вернуть строку/`name` (сейчас грузит `row`, но отдаёт только `{user,workspace}` — [api-key.service.ts:163](apps/server/src/core/api-key/api-key.service.ts#L163)); в `jwt.strategy.validateApiKey` ([:102-129](apps/server/src/core/auth/strategies/jwt.strategy.ts#L102-L129)) застампить `req.raw.apiKeyId` (уже есть) и пометить провенанс как внешне-MCP. `resolveProvenance`/`agentSourceFields` ([auth-provenance.decorator.ts:30-68](apps/server/src/common/decorators/auth-provenance.decorator.ts#L30-L68)) расширить: при наличии `apiKeyId` → `source='agent'` + прокинуть `api_key_id` в набор полей записи. - **Collab-путь (контент страниц)**: `authentication.extension.doAuthenticate` ([:169-180](apps/server/src/collaboration/extensions/authentication.extension.ts#L169-L180)) сейчас возвращает контекст `{user, actor, aiChatId}` — **добавить `apiKeyId`** (из `jwtPayload.apiKeyId`, уже верифицирован для revocation-recheck на :85-96). Далее `persistence.extension` ([:436-449](apps/server/src/collaboration/extensions/persistence.extension.ts#L436-L449), где пишется `lastUpdatedAiChatId`) — писать `lastUpdatedApiKeyId: context?.apiKeyId ?? null`; и `page-history.repo.saveHistory` ([:108-111](apps/server/src/database/repos/page/page-history.repo.ts#L108-L111)) — копировать `lastUpdatedApiKeyId` в снапшот. Также пометить `resolveSource`/`actor='agent'` для `principal='api_key'`. - **Явно определить форму** `source='agent' && aiChatId==null && api_key_id==null` (напр. `is_agent`-аккаунт по обычному ACCESS-токену): решение — оставить существующую внешнюю ветку резолвера (фолбэк-имя), НЕ ломать. Задокументировать маппинг всех трёх форм в `resolveAgentProvenance`. - **Реклассификация личного ключа человека**: любой api_key-write (в т.ч. личный ключ человека в скрипте) станет «MCP внешний». Это **осознанно и честно** (доступ действительно программный, через api_key, а не интерактивный) — зафиксировать в критериях. ### 2. Персист новых колонок (миграция, аддитивная) - `comments.created_api_key_id` (nullable), `pages.last_updated_api_key_id` (nullable), `page_history.last_updated_api_key_id` (nullable). - FK → `api_keys.id` **`.onDelete('set null')`** (критично, адверсариальный ревью): наивное копирование `api_keys.creator_id onDelete cascade` дало бы каскадное удаление атрибутированных страниц/комментов при удалении пользователя — **категорически нет**. `set null` → при удалении → фолбэк-имя. - Миграция без бэкофилла (nullable) → без блокировок горячих таблиц. ### 3. Резолв имени персоны `resolveAgentProvenance` внешняя ветка: имя = join `api_keys.name` по `*_api_key_id` (без фильтра `deletedAt` → переживает revoke, как роль переживает удаление) с фолбэком **«MCP внешний»** (константа рядом с `AGENT_FALLBACK_NAME='AI agent'`). Добавить `apiKeyRoleQuery`-аналог в `page-history.repo` и `comment.repo` (зеркало `aiAgentRoles`-join). `AgentProvenanceInput` расширить полем `api_key_id`/`apiKeyName`. - **Rename ключа**: история покажет **текущее** имя (join к живой строке) — консистентно с тем, как роль-join показывает текущее имя роли. Приемлемо. - **Hard-delete ключа** (удалён владелец/воркспейс, `api_keys` каскадит): `*_api_key_id` → null (set null) → фолбэк «MCP внешний». Историческая атрибуция вырождается в тип без имени — by design. ### 4. Клиент Персона считается сервером — `AgentAvatarStack` менять не надо. Минимум: убедиться, что `source==='agent'` уже покрывает внешний MCP (да, [history-item.tsx:73](apps/client/src/features/page-history/components/history-item.tsx#L73), [comment-list-item.tsx:173](apps/client/src/features/comment/components/comment-list-item.tsx#L173)); опционально добавить в `AgentAvatarStack` tooltip-вариант «MCP внешний» (тип персоны) поверх имени ключа. Типы `lastUpdatedSource`/`createdSource` уже `string` — расширения не требуют. ## Границы scope - Только атрибуция/персона api_key-правок. Авторизация — #557/#558. - Не входит: персона для stdio-CLI; отдельный `ProvenanceSource='external_mcp'` (переиспользуем `'agent'`). ## Критерии приёмки 1. Правка **страницы** внешним MCP-агентом (api_key, collab-путь) → в истории показывается персона «MCP внешний» с именем ключа (`agent-node-2`), НЕ имя человека; `page_history.last_updated_api_key_id` заполнен (не null — регресс-защита от потери `apiKeyId` на collab-пути). 2. **Коммент** внешним MCP-агентом (api_key, REST) → автор показан как «MCP внешний / <имя ключа>»; `comments.created_api_key_id` заполнен. 3. Встроенный AI-агент (внутренний, `aiChatId != null`) по-прежнему показывает имя роли + launcher — **не задет**. 4. Обычная правка человеком (`source='user'`) → плоский человеческий аватар, без персоны. 5. Правки ключа, который затем **отозван** (soft-delete), сохраняют его имя в истории (join без фильтра `deletedAt`). 6. Удаление колонок-владельца (`api_keys` hard-delete через каскад) → правка не удаляется, персона вырождается в «MCP внешний» без имени (FK `set null`). 7. Личный api_key человека → отображается как «MCP внешний» (осознанная реклассификация). ## Отказы и наблюдаемость - Потеря имени ключа (hard-delete / null) → детерминированный фолбэк «MCP внешний», не пустая/сломанная строка. - Никаких новых отказных путей авторизации — только атрибуция. ## Совместимость и миграции - Аддитивная миграция: 3 nullable-колонки + FK `onDelete('set null')`. Обратима (drop columns). Без блокировок (нет бэкофилла). - Изменение поведения: api_key-правки, ранее выглядевшие как человеческие, теперь показываются персоной «MCP внешний» — целевое изменение. ## Решённые развилки - Переиспользовать `source='agent'` + дискриминатор `api_key_id`, не вводить новый `ProvenanceSource` (внешняя ветка резолвера и клиентское ветвление уже есть). - Имя персоны = имя api_key через join (не снапшот строки) — консистентно с роль-join; переживает revoke, вырождается при hard-delete владельца. - FK новых колонок = `onDelete('set null')`, НЕ cascade (иначе удаление юзера сносит контент — адверсариальный ревью). - `apiKeyId` тащится до `persistence.extension`+`saveHistory`, не только до mint (иначе page-правки всегда без имени — адверсариальный ревью). ## Связи - Мета: #556. Blocked by #501 (merged), #506. Независима от #557/#558 технически (api_key REST-путь есть с #501), но часть фазы 3 — приступать после мержа #506.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: vvzvlad/gitmost#559