From 989f99abaec9df1698265064da8d08f9c977a69d Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Tue, 23 Jun 2026 04:25:40 +0300 Subject: [PATCH 1/6] feat(comments): attribute MCP agent comments as AI (unspoofable provenance) Mark comments (and, via existing page provenance, pages) created under an is_agent service account as authored by AI, derived from the SIGNED server identity rather than any client field, and render the existing AI badge in the comments sidebar. Backend (B1): - Add additive users.is_agent boolean (default false) migration; reflect in the Users Kysely type, the user repo baseFields, and (via Selectable) the User entity. - jwt.strategy: derive req.raw.actor from user.isAgent (an is_agent account stamps every write 'agent'); external MCP has no internal ai_chats row so aiChatId stays null. Non-spoofable: a plain user cannot obtain created_source='agent'. - Loosen the provenance aiChatId type to string|null across token.service and the JwtPayload/JwtCollabPayload claims (type-level only; the internal AI-chat path still passes a real aiChatId). Frontend (B2): - Extend IComment with createdSource/aiChatId/resolvedSource (backend already returns them via selectAll). - Extract the local AiAgentBadge from history-item into a shared components/ui/ai-agent-badge.tsx (clickable deep-link when aiChatId present, plain label when null/absent); reuse it in history-item and render it in comment-list-item next to the author name when createdSource==='agent'. Tests: comment.service agent/null-aiChatId provenance, jwt.strategy provenance derivation + anti-spoof, AiAgentBadge clickable/non-clickable branches, and comment-list-item badge render/no-render. Co-Authored-By: Claude Opus 4.8 --- .../src/components/ui/ai-agent-badge.test.tsx | 60 +++++++++ .../src/components/ui/ai-agent-badge.tsx | 92 +++++++++++++ .../components/comment-list-item.test.tsx | 77 +++++++++++ .../comment/components/comment-list-item.tsx | 18 ++- .../features/comment/types/comment.types.ts | 7 + .../page-history/components/history-item.tsx | 93 +------------ apps/server/src/core/auth/dto/jwt-payload.ts | 8 +- .../src/core/auth/services/token.service.ts | 7 +- .../core/auth/strategies/jwt.strategy.spec.ts | 125 ++++++++++++++++++ .../src/core/auth/strategies/jwt.strategy.ts | 19 ++- .../comment/comment.service.behavior.spec.ts | 18 +++ .../20260623T120000-user-is-agent.ts | 23 ++++ .../src/database/repos/user/user.repo.ts | 3 + apps/server/src/database/types/db.d.ts | 1 + 14 files changed, 445 insertions(+), 106 deletions(-) create mode 100644 apps/client/src/components/ui/ai-agent-badge.test.tsx create mode 100644 apps/client/src/components/ui/ai-agent-badge.tsx create mode 100644 apps/client/src/features/comment/components/comment-list-item.test.tsx create mode 100644 apps/server/src/core/auth/strategies/jwt.strategy.spec.ts create mode 100644 apps/server/src/database/migrations/20260623T120000-user-is-agent.ts diff --git a/apps/client/src/components/ui/ai-agent-badge.test.tsx b/apps/client/src/components/ui/ai-agent-badge.test.tsx new file mode 100644 index 00000000..108883e6 --- /dev/null +++ b/apps/client/src/components/ui/ai-agent-badge.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeAll } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { AiAgentBadge } from "./ai-agent-badge"; + +// MantineProvider reads window.matchMedia (color scheme) on mount, which jsdom +// does not implement. Provide a minimal stub so the provider can render. +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }), + }); +}); + +function renderBadge(props: { authorName?: string; aiChatId?: string | null }) { + return render( + + + , + ); +} + +describe("AiAgentBadge", () => { + it("renders the AI-agent label", () => { + renderBadge({ authorName: "Bot" }); + expect(screen.getByText("AI-agent")).toBeDefined(); + }); + + it("is clickable (accessible button) when aiChatId is present", () => { + renderBadge({ authorName: "Bot", aiChatId: "chat-1" }); + const badge = screen.getByRole("button"); + expect(badge).toBeDefined(); + expect(badge.textContent).toContain("AI-agent"); + // Clicking does not throw — the deep-link handler runs against the default + // jotai store. (Asserting the badge exposes an interactive role is the + // observable contract; the atom side-effects are covered by the history UI.) + fireEvent.click(badge); + }); + + it("is a plain non-clickable label when aiChatId is null (external MCP agent)", () => { + renderBadge({ authorName: "Bot", aiChatId: null }); + expect(screen.getByText("AI-agent")).toBeDefined(); + // No interactive role is exposed when there is no chat to deep-link into. + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("is non-clickable when aiChatId is absent", () => { + renderBadge({ authorName: "Bot" }); + expect(screen.queryByRole("button")).toBeNull(); + }); +}); diff --git a/apps/client/src/components/ui/ai-agent-badge.tsx b/apps/client/src/components/ui/ai-agent-badge.tsx new file mode 100644 index 00000000..e7879177 --- /dev/null +++ b/apps/client/src/components/ui/ai-agent-badge.tsx @@ -0,0 +1,92 @@ +import { Badge, Tooltip } from "@mantine/core"; +import { IconSparkles } from "@tabler/icons-react"; +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { useSetAtom } from "jotai"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, + aiChatDraftAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; +import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts"; + +interface AiAgentBadgeProps { + authorName?: string; + aiChatId?: string | null; +} + +/** + * Badge marking content written by the AI agent (provenance C3 / §7.4). It is + * ADDITIVE — shown next to the human author, never replacing them. Reused by the + * page-history list and the comments sidebar. + * + * When the item carries an `aiChatId` (an internal AI-chat edit), clicking the + * badge deep-links into that chat: it sets the active-chat atom, opens the + * floating AI-chat window, and closes the history modal. When `aiChatId` is + * null/absent (an external MCP write with no internal ai_chats row), the badge + * is a plain non-clickable label. The click is contained (stopPropagation) so it + * does not also trigger an enclosing row's click handler. + */ +export function AiAgentBadge({ authorName, aiChatId }: AiAgentBadgeProps) { + const { t } = useTranslation(); + const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); + const setActiveChatId = useSetAtom(activeAiChatIdAtom); + const setDraft = useSetAtom(aiChatDraftAtom); + const setHistoryModalOpen = useSetAtom(historyAtoms); + + const tooltip = t("Edited by AI agent on behalf of {{name}}", { + name: authorName ?? "", + }); + + const openChat = useCallback( + (event: React.SyntheticEvent) => { + event.stopPropagation(); + if (!aiChatId) return; + setActiveChatId(aiChatId); + // Switching to another chat must start with a clean composer — clear any + // unsent draft so it does not leak from the previously open chat. + setDraft(""); + setAiChatWindowOpen(true); + setHistoryModalOpen(false); + }, + [aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, setHistoryModalOpen], + ); + + const badge = ( + } + style={aiChatId ? { cursor: "pointer" } : undefined} + {...(aiChatId + ? { + // Keep the default Badge root element (not a