diff --git a/.env.example b/.env.example index ae932ee5..f13d81cd 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,14 @@ MCP_DOCMOST_PASSWORD= # expose the port publicly). # MCP_TOKEN= # MCP_SESSION_IDLE_MS=1800000 +# +# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"): +# attribution is driven by a per-user `is_agent` flag on the users row. There is +# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service +# account for the MCP fallback above and flag ONLY that account, e.g.: +# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain'; +# NEVER set is_agent on a human or shared account — every action by that account +# (including normal human edits) would then be mis-attributed as AI. # Per-embedding-call timeout in milliseconds for the RAG indexer. # A slow/hung embeddings endpoint fails after this and the batch continues. diff --git a/CHANGELOG.md b/CHANGELOG.md index 43255596..1f03b74b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **AI-agent attribution for MCP writes.** Comments (and pages) created through + the MCP endpoint by a dedicated agent account are now badged as "AI", with + unspoofable provenance derived from a per-user `is_agent` flag (not from the + request body). **Operator setup:** use a *dedicated* service account for the + MCP fallback and set the flag with SQL — + `UPDATE users SET is_agent = true WHERE email = ''`. Never flag a + human or shared account, or its normal edits get mis-attributed as AI. See the + AI-agent block in `.env.example`. (#143) + ### Changed - **Public share AI: default per-workspace hourly assistant cap lowered 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..678013ed --- /dev/null +++ b/apps/client/src/components/ui/ai-agent-badge.test.tsx @@ -0,0 +1,96 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MantineProvider } from "@mantine/core"; +import { Provider, createStore } from "jotai"; +import { AiAgentBadge } from "./ai-agent-badge"; +import { + activeAiChatIdAtom, + aiChatWindowOpenAtom, + aiChatDraftAtom, +} from "@/features/ai-chat/atoms/ai-chat-atom.ts"; + +// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts. + +function renderBadge(props: { authorName?: string; aiChatId?: string | null }) { + return render( + + + , + ); +} + +// Render a clickable badge inside an explicit jotai store, with a leftover draft +// and an onActivate + parent-click spy, so the deep-link side effects are +// assertable. Returns the store and spies. +function setupClickable() { + const store = createStore(); + store.set(aiChatDraftAtom, "leftover draft from another chat"); + const onActivate = vi.fn(); + const onParentClick = vi.fn(); + render( + + +
+ +
+
+
, + ); + return { store, onActivate, onParentClick, badge: screen.getByRole("button") }; +} + +function expectDeepLinked(store: ReturnType, onActivate: ReturnType) { + expect(store.get(activeAiChatIdAtom)).toBe("chat-1"); + expect(store.get(aiChatWindowOpenAtom)).toBe(true); + expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared + expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc. +} + +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"); + }); + + it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => { + const { store, onActivate, onParentClick, badge } = setupClickable(); + fireEvent.click(badge); + expectDeepLinked(store, onActivate); + expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click + }); + + it.each(["Enter", " "])( + "keyboard %j activates the deep-link (same side effects as click)", + (key) => { + const { store, onActivate, badge } = setupClickable(); + fireEvent.keyDown(badge, { key }); + expectDeepLinked(store, onActivate); + }, + ); + + it("an unrelated key does NOT activate the badge", () => { + const { store, onActivate, badge } = setupClickable(); + fireEvent.keyDown(badge, { key: "Tab" }); + expect(store.get(activeAiChatIdAtom)).toBeNull(); + expect(store.get(aiChatWindowOpenAtom)).toBe(false); + expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat"); + expect(onActivate).not.toHaveBeenCalled(); + }); + + it.each([{ aiChatId: null }, {}])( + "is a plain non-clickable label without a chat target (%o)", + (props) => { + renderBadge({ authorName: "Bot", ...props }); + 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(); + }, + ); +}); 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..39e29614 --- /dev/null +++ b/apps/client/src/components/ui/ai-agent-badge.tsx @@ -0,0 +1,99 @@ +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"; + +interface AiAgentBadgeProps { + authorName?: string; + aiChatId?: string | null; + // Fired after the badge deep-links into its chat. The caller handles its own + // context (e.g. the page-history row closes the history modal) so this generic + // ui/ primitive stays free of cross-feature coupling (#143 review Arch B). + onActivate?: () => void; +} + +/** + * 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 and opens the + * floating AI-chat window, then invokes `onActivate` so the caller can react + * (e.g. the history modal closes itself). 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, + onActivate, +}: AiAgentBadgeProps) { + const { t } = useTranslation(); + const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom); + const setActiveChatId = useSetAtom(activeAiChatIdAtom); + const setDraft = useSetAtom(aiChatDraftAtom); + + 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); + onActivate?.(); + }, + [aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate], + ); + + const badge = ( + } + style={aiChatId ? { cursor: "pointer" } : undefined} + {...(aiChatId + ? { + // Keep the default Badge root element (not a