From ba13c0889021927098749f606b4eb28997edd457 Mon Sep 17 00:00:00 2001 From: "jason.woltje" Date: Tue, 14 Jul 2026 20:26:15 +0000 Subject: [PATCH] Fixes #756 (#763) --- .../tess-cross-surface.integration.test.ts | 2 + .../__tests__/agent-service-ownership.test.ts | 12 + apps/gateway/src/agent/agent.service.ts | 42 +- apps/gateway/src/chat/chat.dto.ts | 4 + .../src/chat/chat.gateway-redaction.spec.ts | 55 +- apps/gateway/src/chat/chat.gateway.ts | 309 ++++-- .../plugin/discord-ingress.security.spec.ts | 340 ++++++- apps/gateway/src/plugin/plugin.module.ts | 14 + docs/PRD.md | 85 +- docs/SITEMAP.md | 8 + docs/architecture/channel-protocol.md | 304 +++--- docs/guides/admin-guide.md | 66 +- docs/reports/code-review/756-code-review.md | 27 + .../756-discord-plugin-checklist.md | 36 + docs/reports/security/756-security-review.md | 37 + .../756-official-discord-plugin.md | 57 ++ docs/tess/PLUGIN-GUIDE.md | 25 + docs/tess/USER-GUIDE.md | 8 + eslint.config.mjs | 1 + packages/types/src/channel/channel-adapter.ts | 43 + packages/types/src/channel/channel.dto.ts | 97 ++ packages/types/src/channel/index.ts | 2 + packages/types/src/chat/events.ts | 2 + packages/types/src/index.ts | 1 + plugins/discord/README.md | 67 ++ plugins/discord/package.json | 5 +- plugins/discord/src/index.test.ts | 950 ++++++++++++++++++ plugins/discord/src/index.ts | 629 ++++++++++-- plugins/discord/vitest.config.ts | 10 + pnpm-lock.yaml | 41 +- 30 files changed, 2914 insertions(+), 365 deletions(-) create mode 100644 docs/reports/code-review/756-code-review.md create mode 100644 docs/reports/documentation/756-discord-plugin-checklist.md create mode 100644 docs/reports/security/756-security-review.md create mode 100644 docs/scratchpads/756-official-discord-plugin.md create mode 100644 packages/types/src/channel/channel-adapter.ts create mode 100644 packages/types/src/channel/channel.dto.ts create mode 100644 packages/types/src/channel/index.ts create mode 100644 plugins/discord/README.md create mode 100644 plugins/discord/src/index.test.ts diff --git a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts index fc8b666..22abe32 100644 --- a/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts +++ b/apps/gateway/src/__tests__/integration/tess-cross-surface.integration.test.ts @@ -72,6 +72,7 @@ describe('interaction Discord/CLI durable-session integration', () => { process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-1', channelId: 'channel-1', pairedUsers: { @@ -133,6 +134,7 @@ describe('interaction Discord/CLI durable-session integration', () => { interactionBindings: [ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-1', channelId: 'channel-1', pairedUsers: { diff --git a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts index 83e0368..dc6b9bf 100644 --- a/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts +++ b/apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts @@ -115,6 +115,18 @@ describe('AgentService owner/tenant scope enforcement', () => { ).rejects.toBeInstanceOf(ForbiddenException); await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); + await service.prompt(CONVERSATION_ID, '', OWNER_SCOPE, [ + { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + mimeType: 'image/png', + }, + ]); + expect(session.piSession.prompt).toHaveBeenLastCalledWith( + '\n\n[Untrusted channel attachments]\n' + + '{"id":"attachment-001","name":"diagram.png","mimeType":"image/png","url":"https://cdn.example.test/diagram.png"}', + ); await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf( ForbiddenException, diff --git a/apps/gateway/src/agent/agent.service.ts b/apps/gateway/src/agent/agent.service.ts index 4ac42d5..3819296 100644 --- a/apps/gateway/src/agent/agent.service.ts +++ b/apps/gateway/src/agent/agent.service.ts @@ -15,6 +15,7 @@ import { type ToolDefinition, } from '@mariozechner/pi-coding-agent'; import type { Brain } from '@mosaicstack/brain'; +import type { ChannelAttachmentDto } from '@mosaicstack/types'; import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import { BRAIN } from '../brain/brain.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js'; @@ -43,6 +44,8 @@ export interface ConversationHistoryMessage { role: 'user' | 'assistant' | 'system'; content: string; createdAt: Date; + /** Validated, URI-referenced channel attachments preserved on session resume. */ + attachments?: readonly ChannelAttachmentDto[]; } export interface AgentSessionOptions { @@ -428,7 +431,7 @@ export class AgentService implements OnModuleDestroy { const formatMessage = (msg: ConversationHistoryMessage): string => { const roleLabel = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; - return `**${roleLabel}:** ${msg.content}`; + return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`; }; const formatted = history.map((msg) => formatMessage(msg)); @@ -487,6 +490,21 @@ export class AgentService implements OnModuleDestroy { return result; } + private attachmentContext(attachments: readonly ChannelAttachmentDto[]): string { + if (attachments.length === 0) return ''; + return `\n\n[Untrusted channel attachments]\n${attachments + .map((attachment: ChannelAttachmentDto): string => + JSON.stringify({ + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + url: attachment.url, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), + }), + ) + .join('\n')}`; + } + private resolveModel(options?: AgentSessionOptions) { if (!options?.provider && !options?.modelId) { return this.providerService.getDefaultModel() ?? null; @@ -673,7 +691,19 @@ export class AgentService implements OnModuleDestroy { session.channels.delete(channel); } - async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise { + async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise; + async prompt( + sessionId: string, + message: string, + scope: ActorTenantScope, + attachments: readonly ChannelAttachmentDto[] | undefined, + ): Promise; + async prompt( + sessionId: string, + message: string, + scope: ActorTenantScope, + attachments: readonly ChannelAttachmentDto[] = [], + ): Promise { const session = this.sessions.get(sessionId); if (!session) { throw new Error(`No agent session found: ${sessionId}`); @@ -681,12 +711,16 @@ export class AgentService implements OnModuleDestroy { this.assertSessionScope(session, scope); session.promptCount += 1; + // Channel attachments are untrusted URI references. Preserve exact, + // authenticated metadata for the agent without treating it as authority. + const attachmentContext = this.attachmentContext(attachments); + // Prepend session-scoped system override if present (renew TTL on each turn) - let effectiveMessage = message; + let effectiveMessage = `${message}${attachmentContext}`; if (this.systemOverride) { const override = await this.systemOverride.get(sessionId, scope); if (override) { - effectiveMessage = `[System Override]\n${override}\n\n${message}`; + effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`; await this.systemOverride.renew(sessionId, scope); this.logger.debug(`Applied system override for session ${sessionId}`); } diff --git a/apps/gateway/src/chat/chat.dto.ts b/apps/gateway/src/chat/chat.dto.ts index 8e90297..9bd3586 100644 --- a/apps/gateway/src/chat/chat.dto.ts +++ b/apps/gateway/src/chat/chat.dto.ts @@ -1,3 +1,4 @@ +import type { ChannelAttachmentDto } from '@mosaicstack/types'; import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; export class ChatRequestDto { @@ -32,4 +33,7 @@ export class ChatSocketMessageDto { @IsOptional() @IsUUID() agentId?: string; + + /** Validated channel attachment references; binary content is not embedded. */ + attachments?: readonly ChannelAttachmentDto[]; } diff --git a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts index 23c7060..f43dce3 100644 --- a/apps/gateway/src/chat/chat.gateway-redaction.spec.ts +++ b/apps/gateway/src/chat/chat.gateway-redaction.spec.ts @@ -4,6 +4,10 @@ import { ChatGateway } from './chat.gateway.js'; const CONVERSATION_ID = 'conversation-1'; const CANARY = 'sk_canary12345678'; +function clientConversationKey(clientId: string, conversationId: string): string { + return `${clientId}\u0000${conversationId}`; +} + type GatewayInternals = { clientSessions: Map; relayEvent(client: unknown, conversationId: string, event: unknown): void; @@ -40,6 +44,7 @@ describe('ChatGateway redaction boundary', (): void => { emit: vi.fn(), }; const session = { + clientId: client.id, conversationId: CONVERSATION_ID, cleanup: vi.fn(), assistantText: '', @@ -47,7 +52,7 @@ describe('ChatGateway redaction boundary', (): void => { pendingToolCalls: new Map(), scope: { userId: 'user-1', tenantId: 'tenant-1' }, }; - gateway.clientSessions.set(client.id, session); + gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session); gateway.relayEvent(client, CONVERSATION_ID, { type: 'message_update', @@ -139,6 +144,51 @@ describe('ChatGateway redaction boundary', (): void => { }); }); + it('isolates concurrent conversation streams sharing one Discord socket', (): void => { + const { gateway } = buildGateway(); + const client = { + connected: true, + id: 'discord-client', + data: { user: { id: 'user-1' } }, + emit: vi.fn(), + }; + const firstConversation = 'Nova:discord:thread-1'; + const secondConversation = 'Nova:discord:thread-2'; + const createSession = (conversationId: string) => ({ + clientId: client.id, + conversationId, + cleanup: vi.fn(), + assistantText: '', + toolCalls: [], + pendingToolCalls: new Map(), + scope: { userId: 'user-1', tenantId: 'tenant-1' }, + }); + const firstSession = createSession(firstConversation); + const secondSession = createSession(secondConversation); + gateway.clientSessions.set(clientConversationKey(client.id, firstConversation), firstSession); + gateway.clientSessions.set(clientConversationKey(client.id, secondConversation), secondSession); + + gateway.relayEvent(client, firstConversation, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'first response ' }, + }); + gateway.relayEvent(client, secondConversation, { + type: 'message_update', + assistantMessageEvent: { type: 'text_delta', delta: 'second response ' }, + }); + + expect(firstSession.assistantText).toBe('first response '); + expect(secondSession.assistantText).toBe('second response '); + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: firstConversation, + text: 'first response ', + }); + expect(client.emit).toHaveBeenCalledWith('agent:text', { + conversationId: secondConversation, + text: 'second response ', + }); + }); + it('persists only redacted assistant content with classifications', (): void => { const { gateway, brain } = buildGateway(); const client = { @@ -147,7 +197,8 @@ describe('ChatGateway redaction boundary', (): void => { data: { user: { id: 'user-1' } }, emit: vi.fn(), }; - gateway.clientSessions.set(client.id, { + gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), { + clientId: client.id, conversationId: CONVERSATION_ID, cleanup: vi.fn(), assistantText: CANARY, diff --git a/apps/gateway/src/chat/chat.gateway.ts b/apps/gateway/src/chat/chat.gateway.ts index 9f2541d..188f6cd 100644 --- a/apps/gateway/src/chat/chat.gateway.ts +++ b/apps/gateway/src/chat/chat.gateway.ts @@ -17,6 +17,7 @@ import { parseDiscordInteractionBindings, resolveDiscordInteractionActorId, resolveDiscordInteractionBinding, + type DiscordAttachment, type DiscordIngressEnvelope, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; @@ -30,6 +31,7 @@ import type { SystemReloadPayload, RoutingDecisionInfo, AbortPayload, + ChannelAttachmentDto, } from '@mosaicstack/types'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { @@ -56,6 +58,7 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { + clientId: string; conversationId: string; cleanup: () => void; /** Accumulated assistant response text for the current turn. */ @@ -76,6 +79,68 @@ interface ClientSession { */ const modelOverrides = new Map(); const MAX_REDACTION_BUFFER_LENGTH = 8_192; +const MAX_CHANNEL_ATTACHMENTS = 10; +const MAX_ATTACHMENT_METADATA_BYTES = 16_384; +const MAX_ATTACHMENT_ID_LENGTH = 128; +const MAX_ATTACHMENT_NAME_LENGTH = 255; +const MAX_ATTACHMENT_URL_LENGTH = 2_048; +const MAX_ATTACHMENT_MIME_LENGTH = 255; + +function isSafeAttachmentUrl(value: string): boolean { + if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false; + try { + const url = new URL(value); + return ( + url.protocol === 'https:' && + !url.username && + !url.password && + !url.hash && + url.search.length === 0 + ); + } catch { + return false; + } +} + +function hasValidAttachmentBounds(value: { + id: string; + name: string; + url: string; + sizeBytes?: number; +}): boolean { + return ( + value.id.length > 0 && + value.id.length <= MAX_ATTACHMENT_ID_LENGTH && + value.name.length > 0 && + value.name.length <= MAX_ATTACHMENT_NAME_LENGTH && + isSafeAttachmentUrl(value.url) && + (value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0)) + ); +} + +function isDiscordAttachment(value: unknown): value is DiscordAttachment { + if (typeof value !== 'object' || value === null) return false; + const attachment = value as Partial; + return ( + typeof attachment.id === 'string' && + typeof attachment.name === 'string' && + typeof attachment.url === 'string' && + (attachment.contentType === null || + (typeof attachment.contentType === 'string' && + attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && + (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && + hasValidAttachmentBounds(attachment as DiscordAttachment) + ); +} + +function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean { + return ( + Array.isArray(value) && + value.length <= MAX_CHANNEL_ATTACHMENTS && + JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES && + value.every(guard) + ); +} function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { if (typeof value !== 'object' || value === null) return false; @@ -88,23 +153,49 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo return false; } const payload = envelope.payload as Record; - return [ - payload['correlationId'], - payload['messageId'], - payload['guildId'], - payload['channelId'], - payload['userId'], - payload['conversationId'], - payload['content'], - ].every((field: unknown): boolean => typeof field === 'string'); + return ( + [ + payload['correlationId'], + payload['messageId'], + payload['guildId'], + payload['channelId'], + payload['userId'], + payload['conversationId'], + payload['content'], + ].every((field: unknown): boolean => typeof field === 'string') && + (payload['threadId'] === undefined || typeof payload['threadId'] === 'string') && + (payload['attachments'] === undefined || + hasValidAttachmentArray(payload['attachments'], isDiscordAttachment)) + ); +} + +function isChannelAttachment(value: unknown): value is ChannelAttachmentDto { + if (typeof value !== 'object' || value === null) return false; + const attachment = value as Partial; + return ( + typeof attachment.id === 'string' && + typeof attachment.name === 'string' && + typeof attachment.url === 'string' && + (attachment.mimeType === null || + (typeof attachment.mimeType === 'string' && + attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && + (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && + hasValidAttachmentBounds(attachment as ChannelAttachmentDto) + ); } function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { if (typeof value !== 'object' || value === null) return false; - const payload = value as { content?: unknown; conversationId?: unknown }; + const payload = value as { + content?: unknown; + conversationId?: unknown; + attachments?: unknown; + }; return ( typeof payload.content === 'string' && - (payload.conversationId === undefined || typeof payload.conversationId === 'string') + (payload.conversationId === undefined || typeof payload.conversationId === 'string') && + (payload.attachments === undefined || + hasValidAttachmentArray(payload.attachments, isChannelAttachment)) ); } @@ -174,20 +265,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa handleDisconnect(client: Socket): void { this.logger.log(`Client disconnected: ${client.id}`); - const session = this.clientSessions.get(client.id); - if (session) { + for (const [key, session] of this.clientSessions) { + if (session.clientId !== client.id) continue; session.cleanup(); this.agentService.removeChannel( session.conversationId, `websocket:${client.id}`, session.scope, ); - this.clientSessions.delete(client.id); + this.clientSessions.delete(key); + this.textEgressBuffers.delete(key); + this.thinkingEgressBuffers.delete(key); + this.overflowedEgress.delete(`${key}:agent:text`); + this.overflowedEgress.delete(`${key}:agent:thinking`); } - this.textEgressBuffers.delete(client.id); - this.thinkingEgressBuffers.delete(client.id); - this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); - this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); + } + + private clientConversationKey(client: Pick, conversationId: string): string { + return `${client.id}\u0000${conversationId}`; } private getClientScope(client: Socket): ActorTenantScope | null { @@ -218,7 +313,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } discordIngress = this.resolveDiscordIngress(client, rawData); if (!discordIngress) return; - data = { conversationId: discordIngress.conversationId, content: discordIngress.content }; + data = { + conversationId: discordIngress.conversationId, + content: discordIngress.content, + ...(discordIngress.attachments + ? { + attachments: discordIngress.attachments.map( + (attachment): ChannelAttachmentDto => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + ...(attachment.sizeBytes !== undefined + ? { sizeBytes: attachment.sizeBytes } + : {}), + }), + ), + } + : {}), + }; } else { if (!isChatSocketMessage(rawData)) { this.logger.warn(`Rejected malformed chat message from ${client.id}`); @@ -227,6 +340,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa data = rawData; } const conversationId = data.conversationId ?? uuid(); + const clientConversationKey = this.clientConversationKey(client, conversationId); const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (discordIngress && !discordServiceUserId) { this.logger.warn( @@ -281,7 +395,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa this.logger.log( `Using /model override "${modelOverride}" for conversation=${conversationId}`, ); - } else if (!resolvedProvider && !resolvedModelId) { + } else if (!resolvedProvider && !resolvedModelId && !discordIngress) { // No explicit provider/model from client — use routing engine (M4-012) try { const routingDecision = await this.routingEngine.resolve(data.content, userId); @@ -304,12 +418,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + let resolvedAgentConfigId = data.agentId; + if (discordIngress) { + const binding = this.discordBindingFor(discordIngress, 'send'); + const agentConfig = binding + ? await this.brain.agents.findById(binding.agentConfigId) + : undefined; + if (!binding || !agentConfig || agentConfig.name !== binding.instanceId) { + throw new Error('Configured Discord logical agent is not provisioned'); + } + resolvedAgentConfigId = agentConfig.id; + } + // M5-004: Use existingSessionId as sessionId when available (session reuse) const sessionIdToCreate = existingSessionId ?? conversationId; agentSession = await this.agentService.createSession(sessionIdToCreate, { provider: resolvedProvider, modelId: resolvedModelId, - agentConfigId: data.agentId, + agentConfigId: resolvedAgentConfigId, userId, tenantId: scope.tenantId, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, @@ -360,6 +486,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa discordUserId: discordIngress?.userId, } : {}), + ...(data.attachments && data.attachments.length > 0 + ? { + channelAttachments: data.attachments.map( + (attachment): ChannelAttachmentDto => ({ + ...attachment, + name: redactSensitiveContent(attachment.name).content, + url: redactSensitiveContent(attachment.url).content, + }), + ), + } + : {}), classifications: redactSensitiveContent(data.content).classifications, }, }, @@ -374,7 +511,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Always clean up previous listener to prevent leak - const existing = this.clientSessions.get(client.id); + const existing = this.clientSessions.get(clientConversationKey); if (existing) { existing.cleanup(); } @@ -389,10 +526,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa ); // Preserve routing decision from the existing client session if we didn't get a new one - const prevClientSession = this.clientSessions.get(client.id); + const prevClientSession = this.clientSessions.get(clientConversationKey); const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; - this.clientSessions.set(client.id, { + this.clientSessions.set(clientConversationKey, { + clientId: client.id, conversationId, cleanup, assistantText: '', @@ -438,7 +576,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa // Dispatch to agent try { - await this.agentService.prompt(conversationId, data.content, scope); + await this.agentService.prompt(conversationId, data.content, scope, data.attachments); } catch (err) { this.logger.error( `Agent prompt failed for client=${client.id}, conversation=${conversationId}`, @@ -645,9 +783,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa }; // Emit to all clients currently subscribed to this conversation - for (const [clientId, session] of this.clientSessions) { + for (const session of this.clientSessions.values()) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { - const socket = this.server.sockets.sockets.get(clientId); + const socket = this.server.sockets.sockets.get(session.clientId); if (socket?.connected) { socket.emit('session:info', payload); } @@ -677,16 +815,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa !this.durableSessions ) return; - const binding = resolveDiscordInteractionBinding( - parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), - ingress.guildId, - ingress.channelId, - ingress.userId, - 'approve', - ); + const binding = this.discordBindingFor(ingress, 'approve'); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); - const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); - if (!actorId || !agentName || binding.instanceId !== agentName) { + const agentName = binding?.instanceId; + if (!actorId || !agentName) { this.logger.warn( `Rejected Discord approval without a matching runtime agent from ${client.id}`, ); @@ -774,13 +906,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) return; - const binding = resolveDiscordInteractionBinding( - parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), - ingress.guildId, - ingress.channelId, - ingress.userId, - 'stop', - ); + const binding = this.discordBindingFor(ingress, 'stop'); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); if (!actorId) return; @@ -854,17 +980,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return null; } try { - const binding = resolveDiscordInteractionBinding( - parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), - payload.guildId, - payload.channelId, - payload.userId, - operation, - ); + const binding = this.discordBindingFor(payload, operation); if (!binding) { this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); return null; } + const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`; + if (payload.conversationId !== expectedConversationId) { + this.logger.warn( + `Rejected Discord ingress for a different logical agent from ${client.id}`, + ); + return null; + } } catch { this.logger.warn( `Rejected Discord ingress without valid binding configuration from ${client.id}`, @@ -880,6 +1007,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return payload; } + private discordBindingFor( + payload: DiscordIngressPayload, + operation: 'send' | 'approve' | 'stop', + ) { + return resolveDiscordInteractionBinding( + parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), + payload.guildId, + payload.channelId, + payload.userId, + operation, + ); + } + private readDiscordAllowlist(name: string): string[] { return (process.env[name] ?? '') .split(',') @@ -958,11 +1098,15 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const messages = await this.brain.conversations.findMessages(conversationId, userId); if (messages.length === 0) return []; - return messages.map((msg) => ({ - role: msg.role as 'user' | 'assistant' | 'system', - content: msg.content, - createdAt: msg.createdAt, - })); + return messages.map((msg) => { + const attachments = this.persistedChannelAttachments(msg.metadata); + return { + role: msg.role as 'user' | 'assistant' | 'system', + content: msg.content, + createdAt: msg.createdAt, + ...(attachments ? { attachments } : {}), + }; + }); } catch (err) { this.logger.error( `Failed to load conversation history for conversation=${conversationId}`, @@ -972,6 +1116,14 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } } + private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null { + if (typeof metadata !== 'object' || metadata === null) return null; + const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments; + return hasValidAttachmentArray(attachments, isChannelAttachment) + ? (attachments as readonly ChannelAttachmentDto[]) + : null; + } + private appendAndFlushRedactedEgress( client: Socket, conversationId: string, @@ -979,18 +1131,19 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, delta: string, ): void { - const key = this.egressKey(client, eventName); + const sessionKey = this.clientConversationKey(client, conversationId); + const key = this.egressKey(client, conversationId, eventName); if (this.overflowedEgress.has(key)) return; - const buffered = `${buffers.get(client.id) ?? ''}${delta}`; + const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`; if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { - buffers.delete(client.id); + buffers.delete(sessionKey); this.overflowedEgress.add(key); client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); return; } - buffers.set(client.id, buffered); + buffers.set(sessionKey, buffered); this.flushRedactedEgress(client, conversationId, eventName, buffers, false); } @@ -1006,21 +1159,22 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa buffers: Map, final: boolean, ): void { - const key = this.egressKey(client, eventName); + const sessionKey = this.clientConversationKey(client, conversationId); + const key = this.egressKey(client, conversationId, eventName); if (this.overflowedEgress.has(key)) { if (final) this.overflowedEgress.delete(key); return; } - const buffered = buffers.get(client.id) ?? ''; + const buffered = buffers.get(sessionKey) ?? ''; const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const released = buffered.slice(0, releaseLength); const pending = buffered.slice(releaseLength); if (pending) { - buffers.set(client.id, pending); + buffers.set(sessionKey, pending); } else { - buffers.delete(client.id); + buffers.delete(sessionKey); } if (released) { @@ -1089,8 +1243,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return retainedFrom; } - private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string { - return `${client.id}:${eventName}`; + private egressKey( + client: Socket, + conversationId: string, + eventName: 'agent:text' | 'agent:thinking', + ): string { + return `${this.clientConversationKey(client, conversationId)}:${eventName}`; } private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { @@ -1101,26 +1259,27 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa return; } + const sessionKey = this.clientConversationKey(client, conversationId); switch (event.type) { case 'agent_start': { // Reset accumulation buffers for the new turn - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText = ''; cs.toolCalls = []; cs.pendingToolCalls.clear(); } - this.textEgressBuffers.set(client.id, ''); - this.thinkingEgressBuffers.set(client.id, ''); - this.overflowedEgress.delete(this.egressKey(client, 'agent:text')); - this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking')); + this.textEgressBuffers.set(sessionKey, ''); + this.thinkingEgressBuffers.set(sessionKey, ''); + this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text')); + this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } case 'agent_end': { // Gather usage stats from the Pi session - const activeClientSession = this.clientSessions.get(client.id); + const activeClientSession = this.clientSessions.get(sessionKey); const agentSession = activeClientSession ? this.agentService.getSession(conversationId, activeClientSession.scope) : undefined; @@ -1173,7 +1332,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa } // Persist the assistant message with metadata - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); const userId = (client.data.user as { id: string } | undefined)?.id; if (cs && userId && cs.assistantText.trim().length > 0) { const metadata: Record = { @@ -1225,7 +1384,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa const assistantEvent = event.assistantMessageEvent; if (assistantEvent.type === 'text_delta') { // Keep raw stream material in memory only; persist and emit only redacted text. - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText += assistantEvent.delta; } @@ -1250,7 +1409,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'tool_execution_start': { // Track pending tool call for later recording - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { cs.pendingToolCalls.set(event.toolCallId, { toolName: event.toolName, @@ -1267,7 +1426,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa case 'tool_execution_end': { // Finalise tool call record - const cs = this.clientSessions.get(client.id); + const cs = this.clientSessions.get(sessionKey); if (cs) { const pending = cs.pendingToolCalls.get(event.toolCallId); cs.toolCalls.push({ diff --git a/apps/gateway/src/plugin/discord-ingress.security.spec.ts b/apps/gateway/src/plugin/discord-ingress.security.spec.ts index 0c8bb46..3f12aa2 100644 --- a/apps/gateway/src/plugin/discord-ingress.security.spec.ts +++ b/apps/gateway/src/plugin/discord-ingress.security.spec.ts @@ -24,6 +24,7 @@ const ENV_KEYS = [ 'DISCORD_ALLOWED_CHANNEL_IDS', 'DISCORD_ALLOWED_USER_IDS', 'MOSAIC_AGENT_NAME', + 'MOSAIC_AGENT_CONFIG_ID', ] as const; const savedEnv = new Map(); @@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void { process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova'; process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001'; process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { @@ -141,7 +144,7 @@ function createPayload(overrides: Partial = {}): DiscordI guildId: 'guild-001', channelId: 'channel-001', userId: 'user-001', - conversationId: 'discord-channel-001', + conversationId: 'Nova:discord:channel-001', content: 'hello Tess', ...overrides, }; @@ -153,6 +156,7 @@ describe('Discord ingress security', () => { JSON.stringify([ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': 'admin' }, @@ -170,6 +174,7 @@ describe('Discord ingress security', () => { [ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, @@ -307,41 +312,46 @@ describe('Discord ingress security', () => { ); }); - it.each([ - [ - 'binding', - () => { - process.env['MOSAIC_AGENT_NAME'] = 'Other'; - }, - ], - [ - 'durable session', - (durable: { getSnapshot: ReturnType }) => { - durable.getSnapshot.mockResolvedValueOnce({ - identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, - }); - }, - ], - ])( - 'rejects approval when the %s targets a different runtime agent', - async (_source, configure) => { - configureDiscordEnv(); - const { gateway, client, durable } = discordGateway('admin'); - configure(durable); + it('rejects approval when the durable session targets a different logical agent', async () => { + configureDiscordEnv(); + const { gateway, client, durable } = discordGateway('admin'); + durable.getSnapshot.mockResolvedValueOnce({ + identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, + }); - await gateway.handleDiscordApproval( - client as never, - ingressEnvelope('/approve', 'mismatched-agent-approve'), - ); + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'mismatched-agent-approve'), + ); - expect(client.emit).toHaveBeenCalledWith('discord:approval', { - correlationId: 'correlation-001', - success: false, - approvalId: undefined, - expiresAt: undefined, - }); - }, - ); + expect(client.emit).toHaveBeenCalledWith('discord:approval', { + correlationId: 'correlation-001', + success: false, + approvalId: undefined, + expiresAt: undefined, + }); + }); + + it('rejects privileged envelopes with a forged current conversation route', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + + await gateway.handleDiscordApproval( + client as never, + ingressEnvelope('/approve', 'forged-approval-route', { + conversationId: 'Nova:discord:other-channel', + }), + ); + await gateway.handleDiscordStop( + client as never, + ingressEnvelope('/stop forged', 'forged-stop-route', { + conversationId: 'Nova:discord:other-channel', + }), + ); + + expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything()); + expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything()); + }); it('rejects unpaired and non-admin Discord users for approval and stop', async () => { configureDiscordEnv(); @@ -398,6 +408,267 @@ describe('Discord ingress security', () => { ]); }); + it.each([ + 'https://user:password@cdn.example.test/diagram.png', + 'https://cdn.example.test/diagram.png?token=secret', + 'https://cdn.example.test/diagram.png?X-Amz-Signature=secret', + 'https://cdn.example.test/diagram.png?auth=secret', + 'https://cdn.example.test/diagram.png?hm=secret', + ])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + + await gateway.handleMessage( + client as never, + ingressEnvelope('', `credential-url-${url.length}`, { + conversationId: 'Nova:discord:channel-001', + attachments: [ + { id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' }, + ], + }), + ); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + }); + + it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => { + configureDiscordEnv(); + process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002'; + process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + { + instanceId: 'Orion', + agentConfigId: 'agent-config-orion', + guildId: 'guild-001', + channelId: 'channel-002', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' }, + }, + }, + ]); + const session = { + provider: 'configured-provider', + modelId: 'configured-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const createSession = vi.fn().mockResolvedValue(session); + const agentService = { + getSession: vi.fn().mockReturnValue(undefined), + createSession, + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt: vi.fn().mockResolvedValue(undefined), + }; + const brain = { + agents: { + findById: vi.fn((id: string) => + Promise.resolve({ + id, + name: id === 'agent-config-orion' ? 'Orion' : 'Nova', + }), + ), + }, + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + findMessages: vi.fn().mockResolvedValue([]), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage: vi.fn().mockResolvedValue(undefined), + }, + }; + const routingEngine = { resolve: vi.fn() }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + routingEngine as never, + ); + const client = { + id: 'discord-client-new-session', + data: { discordService: true }, + emit: vi.fn(), + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('start configured session', 'configured-session-001', { + conversationId: 'Nova:discord:channel-001', + }), + ); + + await gateway.handleMessage( + client as never, + ingressEnvelope('start second configured session', 'configured-session-002', { + channelId: 'channel-002', + conversationId: 'Orion:discord:channel-002', + }), + ); + + expect(createSession).toHaveBeenCalledWith( + 'Nova:discord:channel-001', + expect.objectContaining({ + agentConfigId: 'agent-config-nova', + userId: 'discord-service', + tenantId: 'tenant-discord', + }), + ); + expect(createSession).toHaveBeenCalledWith( + 'Orion:discord:channel-002', + expect.objectContaining({ agentConfigId: 'agent-config-orion' }), + ); + expect(routingEngine.resolve).not.toHaveBeenCalled(); + }); + + it('retains validated persisted attachments in resumed conversation history', async () => { + const attachment = { + id: 'attachment-history', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + mimeType: 'image/png', + sizeBytes: 4_096, + }; + const gateway = new ChatGateway( + {} as never, + {} as never, + { + conversations: { + findMessages: vi.fn().mockResolvedValue([ + { + role: 'user', + content: '', + createdAt: new Date('2026-07-14T12:00:00.000Z'), + metadata: { channelAttachments: [attachment] }, + }, + ]), + }, + } as never, + {} as never, + {} as never, + {} as never, + ) as unknown as { + loadConversationHistory( + conversationId: string, + userId: string, + ): Promise>; + }; + + await expect( + gateway.loadConversationHistory('Nova:discord:channel-001', 'discord-service'), + ).resolves.toEqual([expect.objectContaining({ attachments: [attachment] })]); + }); + + it('rejects malformed signed attachment payloads before gateway dispatch', async () => { + configureDiscordEnv(); + const { gateway, client } = discordGateway('admin'); + const malformedPayload: Record = { + ...createPayload({ + messageId: 'malformed-attachments-001', + conversationId: 'Nova:discord:channel-001', + }), + attachments: { id: 'not-an-array' }, + }; + const envelope = createDiscordIngressEnvelope( + malformedPayload as unknown as DiscordIngressPayload, + SERVICE_TOKEN, + ); + + await gateway.handleMessage(client as never, envelope); + + expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything()); + }); + + it('preserves authenticated attachment metadata through persistence and agent dispatch', async () => { + configureDiscordEnv(); + const prompt = vi.fn().mockResolvedValue(undefined); + const addMessage = vi.fn().mockResolvedValue(undefined); + const session = { + provider: 'test-provider', + modelId: 'test-model', + piSession: { + thinkingLevel: 'medium', + getAvailableThinkingLevels: (): string[] => ['medium'], + }, + }; + const agentService = { + getSession: vi.fn().mockReturnValue(session), + recordMessage: vi.fn(), + onEvent: vi.fn().mockReturnValue((): void => undefined), + addChannel: vi.fn(), + prompt, + }; + const brain = { + conversations: { + findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }), + create: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + addMessage, + }, + }; + const gateway = new ChatGateway( + agentService as never, + {} as never, + brain as never, + {} as never, + {} as never, + {} as never, + ); + const client = { + id: 'discord-client-001', + data: { discordService: true }, + emit: vi.fn(), + }; + const attachment = { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.test/diagram.png', + contentType: 'image/png', + sizeBytes: 4_096, + }; + + await gateway.handleMessage( + client as never, + ingressEnvelope('', 'attachment-message-001', { + conversationId: 'Nova:discord:channel-001', + attachments: [attachment], + }), + ); + + const expectedAttachment = { + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + sizeBytes: attachment.sizeBytes, + }; + expect(prompt).toHaveBeenCalledWith( + 'Nova:discord:channel-001', + '', + { userId: 'discord-service', tenantId: 'tenant-discord' }, + [expectedAttachment], + ); + expect(addMessage).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'Nova:discord:channel-001', + metadata: expect.objectContaining({ channelAttachments: [expectedAttachment] }), + }), + 'discord-service', + ); + }); + it('accepts a thread message through its allowed bound parent channel', () => { const emitted = vi.fn(); const plugin = new DiscordPlugin({ @@ -410,6 +681,7 @@ describe('Discord ingress security', () => { interactionBindings: [ { instanceId: 'Nova', + agentConfigId: 'agent-config-nova', guildId: 'guild-001', channelId: 'channel-001', pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, diff --git a/apps/gateway/src/plugin/plugin.module.ts b/apps/gateway/src/plugin/plugin.module.ts index fdf13cf..ecb45a4 100644 --- a/apps/gateway/src/plugin/plugin.module.ts +++ b/apps/gateway/src/plugin/plugin.module.ts @@ -61,6 +61,16 @@ function requiredDiscordAllowlist(name: string): string[] { return value; } +function optionalPositiveInteger(name: string): number | undefined { + const raw = process.env[name]; + if (raw === undefined) return undefined; + const value = Number(raw); + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer when configured`); + } + return value; +} + function createPluginRegistry(): IChannelPlugin[] { const plugins: IChannelPlugin[] = []; const discordToken = process.env['DISCORD_BOT_TOKEN']; @@ -82,6 +92,10 @@ function createPluginRegistry(): IChannelPlugin[] { guildId: discordGuildId, gatewayUrl: discordGatewayUrl, serviceToken: discordServiceToken, + messageRateLimitPerMinute: optionalPositiveInteger( + 'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE', + ), + threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'), allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), diff --git a/docs/PRD.md b/docs/PRD.md index b021392..ddee2d7 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -221,6 +221,69 @@ Delivery uses five gated milestones: runtime contracts/security; Pi service/stat --- +## Official Channel Plugin Workstream (#756) + +### Problem and Objective + +The Discord plugin currently couples Discord event handling, gateway bridging, and reply routing in one implementation and activates only on mentions. Mosaic needs an official channel adapter that behaves the same no matter whether the bound logical agent currently runs through Claude, Codex, Pi, OpenCode, or a future harness. The Discord connection and conversation address must remain stable while the gateway changes the runtime provider behind that logical session. + +The objective is to make Discord the first implementation of a transport-neutral official channel contract, with explicit authorization and deterministic channel/thread routing that future Matrix, Slack, and other adapters can share. + +### Scope + +#### In Scope + +1. `CHN-001`: Transport-neutral channel adapter, route, message, attachment, authorization-principal, response-target, and health contracts in `@mosaicstack/types`, including trusted per-binding logical-agent configuration selection. +2. `CHN-002`: Stable channel conversation addresses based on logical agent plus channel/thread identity; harness, model, and runtime-provider IDs are forbidden from channel session keys. +3. `DSC-001`: An authorized untagged message in a configured agent-bound channel routes to the agent and receives its response in that channel. +4. `DSC-002`: A bot mention in a configured parent channel creates a Discord thread, or reuses the thread already attached to that same native message; the mentioned turn and subsequent thread turns route and respond in that thread. +5. `DSC-003`: A message already inside an authorized thread inherits authorization from its configured parent and never attempts a nested thread. +6. `DSC-004`: Guild, parent channel, user, pairing, and role authorization remains default-deny before thread creation or gateway dispatch. +7. `DSC-005`: Discord service authentication, HMAC envelope integrity, replay protection, attachments, approvals, response chunking, and correlation behavior remain intact. +8. `DSC-006`: The Discord adapter exposes lifecycle and health behavior through the shared channel contract without importing a harness SDK. + +#### Out of Scope + +1. The logical-agent lease, fencing epoch, execution grant, checkpoint, or cross-harness takeover implementation tracked by #754/#755. +2. Dynamic Discord authorization administration in the web UI. +3. Multi-guild tenant isolation, DMs, slash commands, voice, reactions, or production bot deployment. +4. Implementing Matrix or Slack adapters in this slice. + +### Non-Functional Requirements + +1. **Security:** no thread or dispatch side effect occurs until guild, parent channel, user, pairing, role, and bounded per-user/channel rate checks pass; attachment metadata is shape- and size-bounded; credentials never enter source, messages, session keys, or logs. +2. **Portability:** channel contracts and stable conversation IDs contain no Claude, Codex, Pi, OpenCode, model, process, or provider-specific field; each configuration-owned binding selects its trusted logical agent without changing the channel identity. +3. **Reliability:** repeated messages for one channel/thread resolve the same conversation handle; reconnecting the adapter does not require a harness-specific rebinding. +4. **Maintainability:** Discord-specific API translation stays in the Discord package; gateway and future adapters depend on transport-neutral contracts. +5. **Observability:** thread creation or routing failure is reported without message content or credential material. + +### Acceptance Criteria + +1. `AC-CHN-01`: Contract and behavior tests prove the plugin route contains only logical agent plus channel/thread identity and produces the same stable conversation handle regardless of underlying harness selection. +2. `AC-CHN-02`: A mentioned authorized parent-channel message creates a thread (or reuses its already-attached thread), dispatches to the thread conversation, and targets the response to that thread. +3. `AC-CHN-03`: An untagged authorized parent-channel message dispatches to the parent conversation and targets the response to the parent channel. +4. `AC-CHN-04`: Untagged follow-ups inside an authorized thread dispatch and respond in that same thread without creating a nested thread. +5. `AC-CHN-05`: Unauthorized guilds, channels, users, unpaired users, insufficient roles, and rate-limited senders produce no thread and no gateway dispatch. +6. `AC-CHN-06`: Shared channel contracts are exported from `@mosaicstack/types`, Discord implements the lifecycle/health seam, and no harness SDK is imported by the plugin. +7. `AC-CHN-07`: Focused routing/auth tests, package tests, typecheck, lint, formatting, coverage, independent code/security review, and terminal-green CI pass. + +### Constraints, Risks, and Assumptions + +- Dependency: Mosaic gateway remains the policy, durable-session, audit, and runtime-provider boundary. +- Constraint: This work must not modify orchestrator-to-Pi migration or #754/#755 lease/fencing files. +- Risk: accepting untagged messages could create noisy or unintended agent input. Mitigation: only explicitly configured channels and paired, role-authorized users are accepted, with bounded per-user/channel message and thread rates. +- Risk: Discord thread creation can fail because of channel permissions, archived state, or API rate limits. Mitigation: fail without dispatching a turn whose response destination cannot be honored, and emit sanitized diagnostics. +- `ASSUMPTION:` Configured channels are dedicated agent interaction surfaces, so authorized untagged human messages are intentional agent input. +- `ASSUMPTION:` Mention in a parent channel selects a public thread; messages already in a thread remain there because Discord has no nested threads. +- `ASSUMPTION:` One Discord bot may serve multiple configuration-owned logical-agent bindings. +- `ASSUMPTION:` Static allowlists and paired-user roles are the authorization administration surface for this slice. + +### Testing and Delivery Intent + +Use TDD for remote-ingress routing and permission boundaries. Required evidence includes parent-channel mention, untagged parent message, existing-thread follow-up, existing-thread mention, thread reuse, unauthorized side-effect denial, stable harness-neutral conversation identity, adapter health, and regression coverage for signed envelopes and approvals. Deliver through issue #756, a reviewed squash PR to `main`, terminal-green CI, and issue closure. + +--- + ## Architecture ### High-Level System Diagram @@ -575,7 +638,8 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu - Single-guild binding only (v0.1.0) — prevents data leaks between servers - Receives Discord messages, dispatches through gateway routing - Streams agent responses back to Discord (chunked for 2000-char limit) -- Supports mention-based activation, thread management for multi-turn +- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics +- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider - Bot pairing and permission management (Discord user → Mosaic user mapping) - DM support for private conversations @@ -748,10 +812,12 @@ Telegram remote control channel. ### FR-9: Remote Control — Discord -- Discord bot that connects to the gateway -- Mention-based activation in channels +- Discord bot that connects to the gateway through a transport-neutral channel adapter contract +- Authorized messages in configured agent-bound channels work without a mention and respond in-channel +- Mentions in parent channels create threads, or reuse a thread already attached to that same native message, for multi-turn conversations +- Messages already in a thread remain there without requiring repeated mentions +- Stable logical-agent/channel conversation identity survives underlying harness/provider changes - DM support for private conversations -- Thread creation for multi-turn conversations - Chunked message delivery (Discord 2000-char limit) - Bot configuration via web dashboard - Permission management (which Discord users/roles can interact) @@ -935,10 +1001,13 @@ Telegram remote control channel. ### AC-3: Discord Remote Control -- [ ] Discord bot connects and responds to mentions -- [ ] Messages route through gateway to agent pool +- [ ] Discord bot connects through the harness-neutral channel contract +- [ ] Authorized untagged channel messages route through the gateway and respond in-channel +- [ ] Mentioned parent-channel messages create a thread (or reuse their already-attached thread) and respond there +- [ ] Existing-thread follow-ups stay in the thread without repeated mentions +- [ ] Channel/session identity remains stable while the underlying harness/provider changes - [ ] Responses stream back to Discord (chunked) -- [ ] Thread creation for multi-turn conversations +- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message ### AC-4: Gateway Orchestration @@ -1137,7 +1206,7 @@ All work is **alpha** (< 0.1.0) until Jason approves 0.1.0 beta release. 6. ASSUMPTION: **Log summarization uses Haiku-tier LLM by default, configurable.** Haiku is well-suited for summarization (compression, not generation — source material is in context). Guardrails: structured output via Zod schema (force extraction of decisions/tools/outcomes/errors as discrete fields), chunked per-session processing (no bulk conflation), extraction-focused prompts. Raw logs stay in hot tier (7 days) as safety net. Users can override the summarization model via routing engine config if they want higher fidelity. Rationale: Haiku is 10-20x cheaper than Sonnet; log summarization runs on schedule against large volumes where cost matters. -7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — DM support, mention-based channel activation, thread management, chunked responses. Single guild binding to prevent data leaks between servers. Advanced features (voice, components, slash commands, multi-guild) are post-beta. Rationale: Proven pattern from OpenClaw; ship core interaction first; data isolation is non-negotiable. +7. ASSUMPTION: **Discord plugin starts minimal and single-guild only** — explicitly configured agent-bound channels accept authorized untagged messages in-channel, while mentions create threads or reuse a thread already attached to that same native message; responses are chunked. Single guild binding prevents data leaks between servers. DM support, voice, components, slash commands, and multi-guild operation are post-beta. Rationale: Ship the requested core interaction model while preserving default-deny data isolation. 8. ASSUMPTION: **Telegram plugin is lower priority than Discord** and may ship as v0.0.7 or later if Discord takes longer than expected. Rationale: Jason indicated Discord as the high-priority remote channel. diff --git a/docs/SITEMAP.md b/docs/SITEMAP.md index 520ffaf..4644e2e 100644 --- a/docs/SITEMAP.md +++ b/docs/SITEMAP.md @@ -1,5 +1,13 @@ # Documentation Sitemap +## Official channel plugins + +- [Channel protocol architecture](architecture/channel-protocol.md) — shared lifecycle, message, stable-route, authorization, and response-target contracts. +- [Discord administrator configuration](guides/admin-guide.md#discord-ingress-security) — secrets, allowlists, bindings, role policy, and thread permissions. +- [Discord user workflow](tess/USER-GUIDE.md#discord-conversations) — in-channel messages, mention-created threads, and runtime-transparent continuity. +- [Channel plugin authoring](tess/PLUGIN-GUIDE.md#official-channel-adapter-contract) — requirements for future Matrix, Slack, and other official adapters. +- [Discord package guide](../plugins/discord/README.md) — package behavior, configuration shape, and development commands. + ## Native Kanban and canonical task SOT - [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0–P3 requirements and acceptance criteria. diff --git a/docs/architecture/channel-protocol.md b/docs/architecture/channel-protocol.md index 315252a..d2f8c35 100644 --- a/docs/architecture/channel-protocol.md +++ b/docs/architecture/channel-protocol.md @@ -1,9 +1,9 @@ # Channel Protocol Architecture -**Status:** Draft +**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative **Authors:** Mosaic Core Team -**Last Updated:** 2026-03-22 -**Covers:** M7-001 (IChannelAdapter interface), M7-002 (ChannelMessage protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix) +**Last Updated:** 2026-07-14 +**Covers:** M7-001 (OfficialChannelAdapter interface), M7-002 (ChannelMessageDto protocol), M7-003 (Matrix integration design), M7-004 (conversation multiplexing), M7-005 (remote auth bridging), M7-006 (agent-to-agent communication via Matrix), M7-007 (multi-user isolation in Matrix) --- @@ -11,93 +11,80 @@ The channel protocol defines a unified abstraction layer between Mosaic's core messaging infrastructure and the external communication channels it supports (Matrix, Discord, Telegram, TUI, WebUI, and future channels). -The protocol consists of two main contracts: +The implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups: -1. `IChannelAdapter` — the interface each channel driver must implement. -2. `ChannelMessage` — the canonical message format that flows through the system. +1. `OfficialChannelAdapter` — transport lifecycle and connection health. +2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data. +3. `ChannelConversationRouteDto` — stable logical-agent conversation and authorization address. +4. `ChannelResponseTargetDto` — channel/thread destination for replies. -All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects. +All channel-specific translation logic lives inside the adapter implementation. Runtime selection does not: gateway durable-session and provider services may rebind the logical session from Claude to Codex, Pi, OpenCode, or another harness without reconnecting the channel adapter. --- -## M7-001: IChannelAdapter Interface +## M7-001: OfficialChannelAdapter Interface ```typescript -interface IChannelAdapter { - /** - * Stable, lowercase identifier for this channel (e.g. "matrix", "discord"). - * Used as a namespace key in registry lookups and log metadata. - */ +interface OfficialChannelAdapter { + /** Stable, lowercase adapter identifier such as "discord" or "matrix". */ readonly name: string; - - /** - * Establish a connection to the external channel backend. - * Called once at application startup. Must be idempotent (safe to call - * when already connected). - */ - connect(): Promise; - - /** - * Gracefully disconnect from the channel backend. - * Must flush in-flight sends and release resources before resolving. - */ - disconnect(): Promise; - - /** - * Return the current health of the adapter connection. - * Used by the admin health endpoint and alerting. - * - * - "connected" — fully operational - * - "degraded" — partial connectivity (e.g. read-only, rate-limited) - * - "disconnected" — no connection to channel backend - */ - health(): Promise<{ status: 'connected' | 'degraded' | 'disconnected' }>; - - /** - * Register an inbound message handler. - * The adapter calls `handler` for every message received from the channel. - * Multiple calls replace the previous handler (last-write-wins). - * The handler is async; the adapter must not deliver new messages until - * the previous handler promise resolves (back-pressure). - */ - onMessage(handler: (msg: ChannelMessage) => Promise): void; - - /** - * Send a ChannelMessage to the given channel/room/conversation. - * `channelId` is the channel-native identifier (e.g. Matrix room ID, - * Discord channel snowflake, Telegram chat ID). - */ - sendMessage(channelId: string, msg: ChannelMessage): Promise; - - /** - * Map a channel-native user identifier to the Mosaic internal userId. - * Returns null when no matching Mosaic account exists for the given - * channelUserId (anonymous or unlinked user). - */ - mapIdentity(channelUserId: string): Promise; + /** Establish both native-channel and gateway connections. */ + start(): Promise; + /** Gracefully close connections and release resources. */ + stop(): Promise; + /** Best-effort health; ordinary disconnection is a result, not an exception. */ + health(): Promise<{ + status: 'connected' | 'degraded' | 'disconnected'; + detail?: string; + }>; } ``` +The small lifecycle seam lets the gateway host official plugins uniformly without moving native message translation into gateway core. Message ingress remains adapter-owned; gateway policy, durable session routing, auditing, and runtime/provider selection remain gateway-owned. + +### Stable conversation route + +```typescript +interface ChannelConversationRouteDto { + bindingId: string; + logicalAgentId: string; + conversationId: string; + channelName: string; + authorizationChannelId: string; + responseTarget: { channelId: string; threadId?: string }; +} +``` + +Harness, provider, model, process, and native runtime-session identifiers are forbidden from this route. Runtime adapters consume the gateway's durable logical-session binding; channel adapters consume only the stable route and response target. + +### Typed ingress and egress ports + +`ChannelIngressPort` is the transport-neutral direct-integration seam for official adapters. The current deployed Discord adapter preserves its existing HMAC-signed Socket.IO compatibility ingress so gateway-side service authentication, replay protection, approval handling, and correlation semantics remain unchanged; it normalizes the same `ChannelIngressDto` before signing. The adapter uses a supplied `ChannelIngressPort` directly when a future gateway registration provides one. New adapters must use the shared ports rather than adding channel branches to gateway core. + +`ChannelBindingDto` contains the configuration-owned workspace/channel→logical-agent mapping and paired external principals; credentials are absent. After native allowlist, pairing, and role checks pass, an adapter submits `ChannelIngressDto` to `ChannelIngressPort.receive()`. It includes the normalized message, `ChannelAuthorizedPrincipalDto`, operation, correlation ID, native message ID, and stable route. Unauthorized input never reaches the port. + +Gateway policy and runtime routing produce `ChannelEgressDto`, which `ChannelEgressPort.send()` delivers to the route's response target. Discord's existing HMAC envelope is its authenticated wire encoding of this boundary; future Matrix/Slack adapters use their native authenticated transports while preserving the same actor/operation/correlation semantics. + ### Adapter Registration -Adapters are registered with the `ChannelRegistry` service at startup. The registry calls `connect()` on each adapter and monitors `health()` on a configurable interval (default: 30 s). +Adapters are registered with the gateway plugin host at startup. The host calls `start()`/`stop()` and may monitor `health()` on a configurable interval. A richer dynamic `ChannelRegistry` remains a compatible future extension of this lifecycle contract. ``` ChannelRegistry - └── register(adapter: IChannelAdapter): void - └── getAdapter(name: string): IChannelAdapter | null - └── listAdapters(): IChannelAdapter[] + └── register(adapter: OfficialChannelAdapter): void + └── getAdapter(name: string): OfficialChannelAdapter | null + └── listAdapters(): OfficialChannelAdapter[] └── healthAll(): Promise> ``` --- -## M7-002: ChannelMessage Protocol +## M7-002: ChannelMessageDto Protocol ### Canonical Message Format ```typescript -interface ChannelMessage { +interface ChannelMessageDto { /** * Globally unique message ID. * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic @@ -110,6 +97,7 @@ interface ChannelMessage { * The adapter populates this from the inbound message. * For outbound messages, the caller supplies the target channel. */ + channelName: string; channelId: string; /** @@ -119,7 +107,7 @@ interface ChannelMessage { senderId: string; /** Sender classification. */ - senderType: 'user' | 'agent' | 'system'; + senderKind: 'user' | 'agent' | 'system'; /** * Textual content of the message. @@ -136,7 +124,7 @@ interface ChannelMessage { * - "image" — binary image; content is empty, see attachments * - "file" — binary file; content is empty, see attachments */ - contentType: 'text' | 'markdown' | 'code' | 'image' | 'file'; + contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file'; /** * Arbitrary key-value metadata for channel-specific extension fields. @@ -144,7 +132,7 @@ interface ChannelMessage { * Adapters should store channel-native IDs here so round-trip correlation * is possible without altering the canonical fields. */ - metadata: Record; + metadata: Readonly>; /** * Optional thread or reply-chain identifier. @@ -163,18 +151,21 @@ interface ChannelMessage { * Binary or URI-referenced attachments. * Each attachment carries its MIME type and a URL or base64 payload. */ - attachments?: ChannelAttachment[]; + attachments?: readonly ChannelAttachmentDto[]; - /** Wall-clock timestamp when the message was sent/received. */ - timestamp: Date; + /** ISO-8601 wall-clock timestamp when the message was sent/received. */ + timestamp: string; } -interface ChannelAttachment { - /** Filename or identifier. */ +interface ChannelAttachmentDto { + /** Channel-native attachment identifier. */ + id: string; + + /** Filename or display name. */ name: string; - /** MIME type (e.g. "image/png", "application/pdf"). */ - mimeType: string; + /** MIME type when supplied by the channel. */ + mimeType: string | null; /** * URL pointing to the attachment, OR a `data:` URI with base64 payload. @@ -192,23 +183,23 @@ interface ChannelAttachment { ## Channel Translation Reference -The following sections document how each supported channel maps its native message format to and from `ChannelMessage`. +The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`. ### Matrix -| ChannelMessage field | Matrix equivalent | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | -| `channelId` | Matrix room ID (`!roomid:homeserver`) | -| `senderId` | Matrix user ID (`@user:homeserver`) | -| `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | -| `content` | `event.content.body` | -| `contentType` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | -| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | -| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | -| `attachments` | Populated from `url` in `m.image` / `m.file` events | -| `timestamp` | `new Date(event.origin_server_ts)` | -| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | +| ChannelMessageDto field | Matrix equivalent | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | +| `channelId` | Matrix room ID (`!roomid:homeserver`) | +| `senderId` | Matrix user ID (`@user:homeserver`) | +| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | +| `content` | `event.content.body` | +| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | +| `threadId` | `event.content['m.relates_to']['event_id']` when `rel_type = m.thread` | +| `replyToId` | Mosaic ID looked up from `event.content['m.relates_to']['m.in_reply_to']['event_id']` | +| `attachments` | Populated from `url` in `m.image` / `m.file` events | +| `timestamp` | `new Date(event.origin_server_ts)` | +| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | **Outbound:** Adapter sends `m.room.message` with `msgtype = m.text` (or `m.notice` for system messages). Markdown content is sent with `format = org.matrix.custom.html` and a rendered HTML body. @@ -216,21 +207,34 @@ The following sections document how each supported channel maps its native messa ### Discord -| ChannelMessage field | Discord equivalent | -| -------------------- | ----------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | -| `channelId` | Discord channel ID (snowflake string) | -| `senderId` | Discord user ID (snowflake) | -| `senderType` | `"user"` for human members; `"agent"` for bot messages | -| `content` | `message.content` | -| `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) | -| `threadId` | `message.thread.id` when the message is inside a thread channel | -| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | -| `attachments` | `message.attachments` mapped to `ChannelAttachment` | -| `timestamp` | `new Date(message.timestamp)` | -| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | +| ChannelMessageDto field | Discord equivalent | +| ----------------------- | ----------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | +| `channelId` | Discord channel ID (snowflake string) | +| `senderId` | Discord user ID (snowflake) | +| `senderKind` | `"user"` for human members; `"agent"` for bot messages | +| `content` | `message.content` | +| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) | +| `threadId` | `message.thread.id` when the message is inside a thread channel | +| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | +| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` | +| `timestamp` | `new Date(message.timestamp)` | +| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | -**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. +**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentKind = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag. + +### Discord routing and thread policy + +A configured Discord binding maps `(guildId, parentChannelId)` to a stable logical agent and a trusted gateway agent-config ID. Gateway verifies that configuration's name matches the binding logical agent before session creation. The stable conversation handle is derived from logical agent plus response channel/thread and never includes the active harness, provider, model, process, or agent-config ID. + +| Inbound location/trigger | Conversation and response target | +| ------------------------------------------ | --------------------------------------------------------------- | +| Authorized untagged parent-channel message | Parent channel; response is sent in-channel | +| Authorized bot mention in parent channel | Thread already attached to that message, or a new public thread | +| Authorized message already in a thread | Existing thread; no repeated mention and no nested thread | +| `/approve` or `/stop ` | Current parent/thread durable session; no new topic is created | + +Authorization order is fixed: guild allowlist → parent-channel allowlist → user allowlist → configured binding/pairing → operation role → per-user/channel message and thread rate limits → thread creation/dispatch. A normal Discord channel's category parent is never treated as the thread authorization parent. If requested thread creation fails, dispatch does not occur because the adapter cannot honor the response target. ### Discord service ingress security @@ -240,21 +244,21 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket ### Telegram -| ChannelMessage field | Telegram equivalent | -| -------------------- | ------------------------------------------------------------------------------------------------------------- | -| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | -| `channelId` | Telegram `chat_id` (integer as string) | -| `senderId` | Telegram `from.id` (integer as string) | -| `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages | -| `content` | `message.text` or `message.caption` | -| `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | -| `threadId` | `message.message_thread_id` (for supergroup topics) | -| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | -| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` | -| `timestamp` | `new Date(message.date * 1000)` | -| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | +| ChannelMessageDto field | Telegram equivalent | +| ----------------------- | ------------------------------------------------------------------------------------------------------------- | +| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | +| `channelId` | Telegram `chat_id` (integer as string) | +| `senderId` | Telegram `from.id` (integer as string) | +| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages | +| `content` | `message.text` or `message.caption` | +| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | +| `threadId` | `message.message_thread_id` (for supergroup topics) | +| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | +| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` | +| `timestamp` | `new Date(message.date * 1000)` | +| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | -**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. +**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. --- @@ -262,19 +266,19 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the channel protocol so that TUI sessions can be treated as a first-class channel. -| ChannelMessage field | TUI equivalent | -| -------------------- | ------------------------------------------------------------------ | -| `id` | Generated UUID (TUI has no native message IDs) | -| `channelId` | `"tui:"` — the active conversation ID | -| `senderId` | Authenticated Mosaic `userId` | -| `senderType` | `"user"` for human input; `"agent"` for agent replies | -| `content` | Raw text from stdin / agent output | -| `contentType` | `"text"` for input; `"markdown"` for agent responses | -| `threadId` | Not used (TUI sessions are linear) | -| `replyToId` | Not used | -| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | -| `timestamp` | `new Date()` at the moment of send | -| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | +| ChannelMessageDto field | TUI equivalent | +| ----------------------- | ------------------------------------------------------------------ | +| `id` | Generated UUID (TUI has no native message IDs) | +| `channelId` | `"tui:"` — the active conversation ID | +| `senderId` | Authenticated Mosaic `userId` | +| `senderKind` | `"user"` for human input; `"agent"` for agent replies | +| `content` | Raw text from stdin / agent output | +| `contentKind` | `"text"` for input; `"markdown"` for agent responses | +| `threadId` | Not used (TUI sessions are linear) | +| `replyToId` | Not used | +| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | +| `timestamp` | `new Date()` at the moment of send | +| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | **Outbound:** The adapter writes rendered content to stdout. Markdown is rendered via a terminal markdown renderer (e.g. `marked-terminal`). Code blocks are syntax-highlighted when `metadata.colorSupport = true`. @@ -284,19 +288,19 @@ The TUI adapter bridges Mosaic's terminal interface (`packages/cli`) to the chan The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`). -| ChannelMessage field | WebUI equivalent | -| -------------------- | ------------------------------------------------------------ | -| `id` | Generated UUID; echoed back in the WebSocket event | -| `channelId` | `"webui:"` | -| `senderId` | Authenticated Mosaic `userId` | -| `senderType` | `"user"` for browser input; `"agent"` for agent responses | -| `content` | Message text from the input field | -| `contentType` | `"text"` or `"markdown"` | -| `threadId` | Not used (conversation model handles threading) | -| `replyToId` | Message ID the user replied to (UI reply affordance) | -| `attachments` | Files uploaded via the file picker; stored to object storage | -| `timestamp` | `new Date()` at send, or server timestamp from event | -| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | +| ChannelMessageDto field | WebUI equivalent | +| ----------------------- | ------------------------------------------------------------ | +| `id` | Generated UUID; echoed back in the WebSocket event | +| `channelId` | `"webui:"` | +| `senderId` | Authenticated Mosaic `userId` | +| `senderKind` | `"user"` for browser input; `"agent"` for agent responses | +| `content` | Message text from the input field | +| `contentKind` | `"text"` or `"markdown"` | +| `threadId` | Not used (conversation model handles threading) | +| `replyToId` | Message ID the user replied to (UI reply affordance) | +| `attachments` | Files uploaded via the file picker; stored to object storage | +| `timestamp` | `new Date()` at send, or server timestamp from event | +| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | **Outbound:** Adapter emits a `chat:message` Socket.IO event. The WebUI React component receives it and appends to the conversation list. Markdown content is rendered client-side via the existing markdown renderer component. @@ -304,7 +308,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot ## Identity Mapping -`mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts. +Gateway identity-linking policy resolves a channel-native user identifier to a Mosaic `userId` and produces `ChannelAuthorizedPrincipalDto`. Adapters provide native identity evidence but cannot self-authorize Mosaic scope. Discord currently uses configuration-owned paired users; database-backed linking remains the canonical direction for dynamic Matrix/Slack identity. The implementation must query a `channel_identities` table (or equivalent) keyed on `(channel_name, channel_user_id)`. When no mapping exists the method returns `null` and the message is treated as anonymous (no Mosaic session context). @@ -323,8 +327,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out ## Error Handling Conventions -- `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s). -- `sendMessage()` must throw `ChannelSendError` on terminal failures (auth revoked, channel not found). Transient failures (rate limit, network blip) should be retried internally with exponential backoff before throwing. +- `start()` must establish the native channel transport or throw a structured connection error. An adapter hosted inside the gateway must not wait for a loopback connection to that same not-yet-listening process; it starts the native transport, lets Socket.IO reconnect, and reports `degraded` until both links are ready. +- `ChannelEgressPort.send()` implementations must throw a typed terminal error for revoked auth, an invalid route, or a missing channel. Only transient rate/network/server failures are retried with bounded exponential backoff; Discord retries reuse a stable enforced nonce to prevent duplicate chunks, while permanent 4xx failures are not retried. - `health()` must never throw — it returns `{ status: 'disconnected' }` on error. - Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability. @@ -332,7 +336,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out ## Versioning -The `ChannelMessage` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. +The `ChannelMessageDto` protocol follows semantic versioning. Non-breaking field additions (new optional fields) are minor version bumps. Breaking changes (type changes, required field additions) require a major version bump and a migration guide. Current version: **1.0.0** @@ -473,7 +477,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac ### Real-Time Sync Flow 1. A message arrives on any surface (TUI keystroke, browser send, Matrix event). -2. The surface's adapter normalizes the message to `ChannelMessage` and delivers it to `ConversationService`. +2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`. 3. `ConversationService` persists the message to PostgreSQL, assigns a canonical `id`, and publishes a `message:new` event to the Valkey pub/sub channel keyed by `conversationId`. 4. All active surfaces subscribed to that `conversationId` receive the fanout event and push it to their respective clients: - TUI adapter: writes rendered output to the connected terminal session. @@ -561,7 +565,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi - Their `channel_identities` row exists (link not revoked). - They remain members of the relevant Matrix rooms. -Revoking a Matrix link (`DELETE /auth/channel-link/matrix/`) removes the `channel_identities` row and causes `mapIdentity()` to return `null`. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off). +Revoking a Matrix link (`DELETE /auth/channel-link/matrix/`) removes the `channel_identities` row and causes gateway principal resolution to deny the identity. The appservice optionally kicks the Matrix user from all Mosaic-managed rooms as part of the revocation flow (configurable, default: off). --- @@ -733,7 +737,7 @@ room_retention_policies created_at TIMESTAMP ``` -The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessage` record unless the Mosaic retention policy also covers it. +The retention policy is enforced by a background job in the gateway that calls Conduit's admin API to purge events older than the configured threshold. Purged events are removed from the Conduit store but Mosaic's PostgreSQL message store retains the canonical `ChannelMessageDto` record unless the Mosaic retention policy also covers it. Default retention values: diff --git a/docs/guides/admin-guide.md b/docs/guides/admin-guide.md index e6a098b..e21c5c6 100644 --- a/docs/guides/admin-guide.md +++ b/docs/guides/admin-guide.md @@ -293,24 +293,64 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe ### Plugins -| Variable | Description | -| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | -| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | -| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | -| `DISCORD_GUILD_ID` | Discord guild/server ID | -| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | -| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | -| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | -| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | -| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | -| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | +| Variable | Description | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | +| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only | +| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata | +| `DISCORD_GUILD_ID` | Discord guild/server ID | +| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | +| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny | +| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny | +| `DISCORD_INTERACTION_BINDINGS` | Required JSON bindings from guild/channel to logical agent and paired Discord users with `viewer`, `operator`, or `admin` roles | +| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`) | +| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Optional positive integer; mention-thread routes per guild/channel/user each minute (default: `5`) | +| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) | +| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call | ### Discord ingress security When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged. -Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. +Inbound Discord messages must originate from an allowed guild and configured parent channel, come from an allowed and paired user whose role permits sending, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. Attachment references are limited in count, metadata size, field length, and declared size; only query-free HTTPS URLs without credentials or fragments are accepted, so bearer or presigned URLs never reach persistence or an agent prompt. The gateway validates the service identity, envelope signature, allowlists, pairing, and role again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state. + +Configured channels are dedicated agent interaction surfaces. An authorized untagged message routes to the bound logical agent and the response returns in that channel. Mentioning the bot on a normal channel message creates a public Discord thread, or reuses the thread already attached to that same message; the response and later thread messages stay in that thread without repeated mentions. A normal channel's category is not an authorization parent—only a Discord thread inherits authorization from its configured parent channel. Runtime control commands such as `/approve` and `/stop ` remain on the current channel/thread because they target that durable session rather than opening a new topic. + +Authorization and per-user/channel rate limits are evaluated before thread creation, so an unlisted guild/channel/user, unpaired user, `viewer`, or rate-limited sender cannot create bot threads or dispatch gateway work. The bot needs Discord permissions to view/send in configured channels and create/send in public threads. If thread creation fails, the turn is not dispatched because the requested response destination cannot be honored. + +Conversation handles use the configured logical agent plus Discord channel/thread identity. They do not contain a Claude, Codex, Pi, OpenCode, model, process, or runtime-provider identifier; changing the runtime behind the logical session therefore does not require reconnecting the Discord bot. + +#### Interaction binding format + +`DISCORD_INTERACTION_BINDINGS` must be a non-empty JSON array. Each item requires `instanceId`, trusted `agentConfigId`, `guildId`, `channelId`, and a non-empty `pairedUsers` object. `instanceId` is the configured logical-agent name; its trusted database `agentConfigId` must resolve to an agent configuration with exactly that name, preserving provider/model/prompt/tool selection per binding. The guild/channel must also appear in their corresponding allowlists. IDs below are placeholders: + +```json +[ + { + "instanceId": "interaction-agent", + "agentConfigId": "agent-config-id", + "guildId": "guild-id", + "channelId": "channel-id", + "pairedUsers": { + "discord-user-id": { + "role": "operator", + "mosaicUserId": "mosaic-user-id" + } + } + } +] +``` + +| Pairing role | Send message | Create/continue thread | Approve | Stop | +| ------------ | ------------ | ---------------------- | ------- | ---- | +| `viewer` | No | No | No | No | +| `operator` | Yes | Yes | No | No | +| `admin` | Yes | Yes | Yes | Yes | + +A legacy role-only value such as `"discord-user-id": "operator"` remains valid for non-privileged ingress. Approval and stop require the object form with a provisioned `mosaicUserId`; gateway policy checks that Mosaic identity and consumes one exact-action approval once. Do not make the Discord service principal an approving administrator. + +After changing bindings or allowlists, restart the gateway/plugin through the normal service manager and verify both Discord and gateway connectivity. The adapter reports `connected` only when both links are ready, `degraded` when one is ready, and `disconnected` when neither is ready. Test one authorized untagged channel turn, one mention-created thread, one thread follow-up, and one unauthorized user denial without using production credential values in logs or evidence. ### Session retention and garbage collection diff --git a/docs/reports/code-review/756-code-review.md b/docs/reports/code-review/756-code-review.md new file mode 100644 index 0000000..fb8fd18 --- /dev/null +++ b/docs/reports/code-review/756-code-review.md @@ -0,0 +1,27 @@ +# Independent Code Review — #756 Official Discord Channel Plugin + +**Verdict: APPROVE** + +## Scope reviewed + +Complete current uncommitted #756 delta: multi-binding trusted agent selection, privileged ingress-route validation, attachment validation/persistence/resume, egress route lifecycle and idempotency, concurrent gateway stream state, Discord lifecycle/thread/rate behavior, compatibility ingress, and tests. + +## Review result + +No blocking or change-request finding remains. + +- **Trusted multi-agent routing:** each binding requires a provisioned `agentConfigId`; the gateway resolves that config server-side, verifies its logical-agent name, and never accepts a Discord-controlled agent selection or applies generic routing to Discord ingress. +- **Auth and route integrity:** allowlist, pairing, role, and canonical logical-agent/channel-or-thread conversation-route validation occur before gateway processing. Privileged approval/stop paths use the same binding and route validation. +- **Attachments:** ingress rejects malformed, over-bounded, credential-bearing, fragment-bearing, or query-bearing URLs. Valid attachment metadata, including `sizeBytes`, persists and is reconstructed into resume history as explicitly untrusted context. +- **Discord reliability:** the adapter supports degraded Socket.IO reconnect, parent/thread routing semantics, bounded pre-side-effect ingress rates, and terminal response-route cleanup. Egress validates route/message alignment, sends deterministic nonces, distinguishes permanent from transient errors, and uses bounded retries. +- **Concurrent state:** per-client/conversation keys isolate listener, redaction, tool, and stream state for simultaneous threads sharing a Discord socket; disconnect cleanup covers all associated conversations. +- **Harness neutrality:** contracts retain logical-agent/channel data only; no harness/provider identity leaks into adapter routes or message boundaries. + +## Verification performed + +- `git diff --check` — passed. +- `pnpm --filter @mosaicstack/discord-plugin typecheck` — passed. +- `pnpm --filter @mosaicstack/discord-plugin lint` — passed. +- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; coverage 92.18% statements/lines, 86.55% branches, 100% functions (all ≥85% threshold). +- `pnpm --filter @mosaicstack/gateway typecheck` — passed. +- `cd apps/gateway && pnpm exec vitest run src/plugin/discord-ingress.security.spec.ts src/chat/chat.gateway-redaction.spec.ts src/__tests__/integration/tess-cross-surface.integration.test.ts` — passed: 32 tests. diff --git a/docs/reports/documentation/756-discord-plugin-checklist.md b/docs/reports/documentation/756-discord-plugin-checklist.md new file mode 100644 index 0000000..4fae203 --- /dev/null +++ b/docs/reports/documentation/756-discord-plugin-checklist.md @@ -0,0 +1,36 @@ +# Documentation Completion Checklist — #756 Official Discord plugin + +## Required artifacts + +- [x] `docs/PRD.md` includes the #756 workstream, assumptions, and acceptance criteria. +- [x] User workflow updated in `docs/tess/USER-GUIDE.md`. +- [x] Administrator configuration and authorization policy updated in `docs/guides/admin-guide.md`. +- [x] Developer/plugin authoring guidance updated in `docs/tess/PLUGIN-GUIDE.md`. +- [x] Channel architecture updated in `docs/architecture/channel-protocol.md`. +- [x] Package operations/development guide added at `plugins/discord/README.md`. +- [x] `docs/SITEMAP.md` links the official channel plugin documentation. + +## API coverage + +- [x] No HTTP or WebSocket endpoint was added, removed, or changed. +- [x] No OpenAPI update is needed. +- [x] Shared TypeScript contracts are documented in architecture and plugin-authoring guides. +- [x] Discord authentication, authorization, thread failure, and control-command behavior are documented. + +## Structural standards + +- [x] Working notes remain under `docs/scratchpads/`. +- [x] Review and checklist artifacts remain under `docs/reports/`. +- [x] No generated publishing output was added. +- [x] Existing repository documentation structure was preserved; no unrelated root cleanup was attempted. + +## Review gate + +- [x] Independent documentation/contract review passes (shared-contract review plus final code review of the current documentation delta). +- [x] Independent code review verifies documentation matches implementation (`docs/reports/code-review/756-code-review.md`: APPROVE). +- [x] Independent security review verifies documented controls (`docs/reports/security/756-security-review.md`: APPROVE). + +## Publishing + +- [x] Canonical source remains in-repository. +- [x] No external publishing action is in scope for this slice. diff --git a/docs/reports/security/756-security-review.md b/docs/reports/security/756-security-review.md new file mode 100644 index 0000000..8f124cc --- /dev/null +++ b/docs/reports/security/756-security-review.md @@ -0,0 +1,37 @@ +# Security Review — Issue #756 + +**Scope:** final current uncommitted Discord plugin, shared channel contract, gateway ingress, AgentService, and plugin registration delta +**Snapshot:** `plugins/discord/src/index.ts` SHA-256 `5ee6b6aa4e2ff349f137f76b918d02c1254e12066cb48b136048e57bef36fdb2` +**Verdict:** **APPROVE** + +## Final remediation verification + +| Area | Current evidence | Result | +|---|---|---| +| Attachment confidentiality and integrity | Ingress accepts bounded attachment metadata only when URL is HTTPS, query-free, fragment-free, and credential-free. Count, aggregate size, field length, MIME, and finite non-negative size validation apply before dispatch; `sizeBytes` is signed and retained. | Pass | +| Trusted agent selection | Each binding names a required trusted `agentConfigId`; gateway resolves that config server-side and requires its configured name to equal the binding logical-agent ID. Client/provider input cannot select the agent for Discord ingress. | Pass | +| Privileged operation routing | Gateway revalidates the binding and requires the signed conversation identity to match the configured logical agent before approve/stop actions. Paired admin identity and one-time approval checks remain enforced. | Pass | +| Egress containment and delivery | Egress requires an aligned message/route, configured parent or exact observed thread target, and cleans response routes after completion, errors, typed-ingress failure, or bounded-map pressure. | Pass | +| Side-effect limits | Per guild/authorized-parent/user rolling message and thread limits execute before thread creation or dispatch. | Pass | + +## Security controls reviewed + +- Default-deny guild, parent-channel, user, configured pairing, and role checks precede rate consumption and all side effects. +- Gateway independently enforces Discord service authentication, HMAC integrity, allowlists, binding/role checks, attachment validation, and replay-ID rejection. +- Thread authorization uses only the Discord thread parent; category parents cannot authorize ingress. +- Agent-visible attachment references are explicitly labeled untrusted; binary content is not embedded. Persisted attachment name/URL values are redacted. +- Egress uses deterministic nonces, bounded transient retry, typed terminal errors, and does not send to forged targets. +- No secrets or message content were added to plugin logs. + +## Verification evidence + +| Command | Result | +|---|---| +| `pnpm --filter @mosaicstack/discord-plugin test` | PASS — 44 tests; v8 coverage: 92.18% statements/lines, 86.55% branches, 100% functions | +| `pnpm --filter @mosaicstack/discord-plugin typecheck` | PASS | +| `pnpm --filter @mosaicstack/types typecheck` | PASS | +| `pnpm --filter @mosaicstack/gateway typecheck` | PASS | +| `pnpm --filter @mosaicstack/gateway exec vitest run src/plugin/discord-ingress.security.spec.ts src/agent/__tests__/agent-service-ownership.test.ts` | PASS — 29 tests | +| `git diff --check` | PASS | + +No unresolved critical, high, medium, or low security findings were identified in the reviewed final delta. diff --git a/docs/scratchpads/756-official-discord-plugin.md b/docs/scratchpads/756-official-discord-plugin.md new file mode 100644 index 0000000..78b23a0 --- /dev/null +++ b/docs/scratchpads/756-official-discord-plugin.md @@ -0,0 +1,57 @@ +# Scratchpad — #756 Official Discord channel plugin + +- **Task / issue:** Official Discord channel plugin / #756 +- **Branch:** `feat/756-official-discord-plugin` +- **Worktree:** `/home/hermes/agent-work/stack-discord-plugin` +- **Base:** `origin/main` at `49e8a54105eddf41e8e0e44603ded616ee76044f` +- **Objective:** Deliver harness-neutral Discord routing, native mention-to-thread behavior, untagged in-channel interaction, fail-closed channel/user authorization, and transport-neutral contracts for future official channel plugins. +- **Collision boundary:** Do not modify orchestrator-to-Pi migration, logical-agent lease/fencing (#754/#755), runtime provider implementations, or orchestrator-owned `docs/TASKS.md`. +- **Working budget:** 55K tokens for requirements, implementation, tests, documentation, independent review, and delivery. No user-specified hard cap. Reduce optional refactoring before reducing acceptance coverage. + +## Assumptions + +1. **ASSUMPTION:** Every configured Discord channel is intentionally agent-bound, so an authorized human's untagged message is agent input and receives an in-channel response. Rationale: this satisfies the requested no-tag behavior without activating the bot in arbitrary channels. +2. **ASSUMPTION:** Mentioning the bot in a parent channel creates or reuses a public Discord thread; messages already in a thread remain in that thread without repeated mentions. Rationale: Discord does not support nested threads and the request describes tags as the thread-selection signal. +3. **ASSUMPTION:** One bot process may host multiple configured channel-to-logical-agent bindings. Rationale: bindings are already configuration-owned and this avoids per-agent Discord credentials. +4. **ASSUMPTION:** Static guild/channel/user allowlists and paired-user roles remain the administration surface for this slice. Rationale: dynamic admin UI is larger and can be added without changing adapter contracts. +5. **ASSUMPTION:** The stable conversation handle contains logical agent plus Discord channel/thread identity and never a harness/provider identifier. Rationale: runtime re-enrollment and the active lease work can change Claude/Codex/Pi/OpenCode behind the same channel connection. +6. **ASSUMPTION:** Canonical documentation remains in-repository for this slice; no external publishing is performed. + +## Plan + +1. Update `docs/PRD.md` before code with #756 scope, constraints, assumptions, and acceptance criteria. +2. Add failing Discord behavior and authorization tests first. +3. Add transport-neutral channel DTO/contracts in `@mosaicstack/types`. +4. Implement mention-to-thread, untagged in-channel, existing-thread, stable conversation, and adapter health behavior without runtime-specific imports. +5. Update admin/developer/channel protocol docs and documentation checklist. +6. Run focused tests, typecheck, lint, formatting, full applicable baseline, and coverage. +7. Run independent code and security review; remediate and re-review. +8. Commit, queue-guard, push, open PR to `main`, wait for green CI, squash merge, verify merged CI, and close #756. + +## Progress + +- 2026-07-14: Loaded mission state (none active), global/project guidance, current `origin/main`, existing Discord/Tess/fleet connector architecture, issue #709 history, and active portability issues #754/#755. +- 2026-07-14: Created issue #756 through the Mosaic wrapper. +- 2026-07-14: Created isolated worktree/branch from current `origin/main`; the stale root checkout and unrelated QA artifacts remain untouched. + +## TDD decision + +Required and applied. This change modifies authorization-sensitive remote ingress and routing behavior. Failing permission and routing tests will be captured before implementation. + +## Risks / blockers + +- Active logical-agent lease work may later add stronger fencing fields. This slice must expose a clean, harness-neutral seam without duplicating that schema. +- Discord thread creation is an external side effect. Unit tests use a typed fake; live credential smoke testing is out of scope and must not use committed secrets. +- Repository-wide checks may expose unrelated baseline debt; changed-scope evidence and CI remain mandatory. + +## Verification evidence + +- `pnpm format:check` — passed. +- `git diff --check` — passed. +- `pnpm typecheck` — passed (42 Turbo tasks). +- `pnpm lint` — passed (23 Turbo tasks). +- `pnpm build` — passed (23 Turbo tasks). +- `pnpm --filter @mosaicstack/discord-plugin test` — passed: 44 tests; V8 coverage 92.18% statements/lines, 86.55% branches, 100% functions (all configured thresholds ≥85%). +- Focused gateway verification passed: Discord ingress/security, cross-surface, ownership, redaction/concurrency, and agent attachment tests. +- `pnpm test` — changed-scope suites passed; repository baseline remains blocked by `apps/gateway/src/__tests__/cross-user-isolation.test.ts` requiring PostgreSQL at localhost port 5433 (`ECONNREFUSED`). The failure is unrelated to this change. +- Independent code/security re-reviews requested after final remediation; reports are stored under ignored `docs/reports/` evidence paths. diff --git a/docs/tess/PLUGIN-GUIDE.md b/docs/tess/PLUGIN-GUIDE.md index 4d40348..efa54fd 100644 --- a/docs/tess/PLUGIN-GUIDE.md +++ b/docs/tess/PLUGIN-GUIDE.md @@ -1,3 +1,28 @@ # Tess Plugin Authoring Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults. + +## Official channel adapter contract + +Official Discord, Matrix, Slack, and future channel adapters share contracts exported from `@mosaicstack/types` under `channel/`: + +- `OfficialChannelAdapter` provides `name`, `start()`, `stop()`, and non-throwing connection `health()`. +- `ChannelMessageDto` and `ChannelAttachmentDto` normalize transport data with JSON-safe metadata. +- `ChannelBindingDto` and `ChannelAuthorizedPrincipalDto` normalize configuration-owned logical-agent binding and the already-allowlisted/paired external actor. +- `ChannelIngressDto` carries operation, correlation, native message ID, authorized principal, normalized message, and stable route into `ChannelIngressPort`. +- `ChannelConversationRouteDto` binds a configured channel to `logicalAgentId`, stable `conversationId`, authorization parent, and response target. +- `ChannelEgressDto` and `ChannelEgressPort` separate where a response is delivered from the gateway's runtime/provider selection. + +`ChannelConversationRouteDto` deliberately has no harness, provider, model, process, or native runtime-session field. The gateway owns runtime selection, durable enrollment, authorization, audit, and lease/fencing. A channel adapter must not call Claude, Codex, Pi, OpenCode, tmux, or Matrix runtime providers directly. Discord currently preserves its signed Socket.IO compatibility ingress for established gateway authentication/replay/approval controls while normalizing the same ingress DTO; supplied direct ports are the future registration path. + +## Adapter requirements + +1. Resolve configuration-owned channel and logical-agent bindings before dispatch. A binding may carry a trusted gateway agent-config reference, but the stable route contains only the logical agent; gateway verifies the reference resolves to that agent before runtime selection. +2. Apply channel-native allowlists and paired-user roles before any external side effect such as thread creation. +3. Preserve native message ID, correlation ID, channel/thread address, attachments, and response target. +4. Treat normal channel parents (for example Discord categories) separately from thread parents. +5. Keep reconnect and conversation identity independent of the active runtime provider. +6. Report sanitized connection/routing failures without message bodies or credentials. +7. Pass the shared route/authorization contract suite plus adapter-specific translation tests. + +Discord establishes the first policy: authorized untagged messages respond in the configured channel; a mention creates a thread or reuses the thread already attached to that message; existing thread messages stay there. Runtime control commands remain on the current durable session. Matrix and Slack should translate native rooms/threads into the same route and response-target semantics rather than adding transport branches to gateway core. diff --git a/docs/tess/USER-GUIDE.md b/docs/tess/USER-GUIDE.md index 90a85f1..908ba61 100644 --- a/docs/tess/USER-GUIDE.md +++ b/docs/tess/USER-GUIDE.md @@ -1,5 +1,13 @@ # Tess User Guide +## Discord conversations + +In a configured Tess/interaction channel, an authorized untagged message is sent to the bound logical agent and its response appears in the channel. Mention the bot when starting a separate topic: Mosaic reuses a thread already attached to that same Discord message, or creates a new thread for the message, and responds there. Continue in that thread without tagging the bot again. Messages from unconfigured channels or users without an authorized pairing are ignored without creating a thread. + +`/approve` and `/stop ` operate on the current channel/thread session and do not open a new thread. The Discord connection is bound to the logical agent conversation, not Claude, Codex, Pi, OpenCode, or another harness; a runtime handoff behind Mosaic does not change where you continue the conversation. + +## CLI and HTTP interaction + All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work. Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID. diff --git a/eslint.config.mjs b/eslint.config.mjs index bc27014..0433f3a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,6 +28,7 @@ export default tseslint.config( 'apps/web/e2e/helpers/*.ts', 'apps/web/playwright.config.ts', 'apps/gateway/vitest.config.ts', + 'plugins/discord/vitest.config.ts', 'packages/db/vitest.config.ts', 'packages/storage/vitest.config.ts', 'packages/mosaic/vitest.config.ts', diff --git a/packages/types/src/channel/channel-adapter.ts b/packages/types/src/channel/channel-adapter.ts new file mode 100644 index 0000000..e10b9e6 --- /dev/null +++ b/packages/types/src/channel/channel-adapter.ts @@ -0,0 +1,43 @@ +import type { + ChannelAdapterHealthDto, + ChannelEgressDto, + ChannelIngressDto, +} from './channel.dto.js'; + +export type ChannelDeliveryErrorCode = + | 'invalid_route' + | 'destination_unavailable' + | 'delivery_failed'; + +/** Terminal adapter delivery failure surfaced to the gateway/caller. */ +export class ChannelDeliveryError extends Error { + readonly name = 'ChannelDeliveryError'; + + constructor( + readonly code: ChannelDeliveryErrorCode, + message: string, + readonly retryable = false, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Gateway policy boundary consumed by official channel adapters. */ +export interface ChannelIngressPort { + receive(ingress: ChannelIngressDto): Promise; +} + +/** Adapter egress boundary used by the gateway after agent output is ready. */ +export interface ChannelEgressPort { + send(egress: ChannelEgressDto): Promise; +} + +/** Shared lifecycle seam implemented by every official channel adapter. */ +export interface OfficialChannelAdapter { + readonly name: string; + start(): Promise; + stop(): Promise; + /** Health is best-effort and never throws for ordinary disconnected state. */ + health(): Promise; +} diff --git a/packages/types/src/channel/channel.dto.ts b/packages/types/src/channel/channel.dto.ts new file mode 100644 index 0000000..9da8160 --- /dev/null +++ b/packages/types/src/channel/channel.dto.ts @@ -0,0 +1,97 @@ +/** JSON-safe metadata carried across channel adapter boundaries. */ +export type ChannelMetadataValue = + | string + | number + | boolean + | null + | readonly ChannelMetadataValue[] + | { readonly [key: string]: ChannelMetadataValue }; + +export type ChannelSenderKind = 'user' | 'agent' | 'system'; +export type ChannelContentKind = 'text' | 'markdown' | 'code' | 'image' | 'file'; +export type ChannelAdapterStatus = 'connected' | 'degraded' | 'disconnected'; +export type ChannelAuthorizationRole = 'viewer' | 'operator' | 'admin'; +export type ChannelOperation = 'message.send' | 'approval.create' | 'session.stop'; + +export interface ChannelAttachmentDto { + id: string; + name: string; + mimeType: string | null; + url: string; + sizeBytes?: number; +} + +/** Canonical transport-neutral message shape for official channel adapters. */ +export interface ChannelMessageDto { + id: string; + channelName: string; + channelId: string; + senderId: string; + senderKind: ChannelSenderKind; + content: string; + contentKind: ChannelContentKind; + timestamp: string; + threadId?: string; + replyToId?: string; + attachments?: readonly ChannelAttachmentDto[]; + metadata: Readonly>; +} + +/** Where an adapter must deliver a response for one normalized conversation turn. */ +/** Provisioned external identity after adapter allowlist/pairing checks pass. */ +export interface ChannelAuthorizedPrincipalDto { + channelUserId: string; + role: ChannelAuthorizationRole; + /** Needed when gateway policy must authorize a privileged Mosaic operation. */ + mosaicUserId?: string; +} + +/** Configuration-owned binding. Credentials are intentionally absent. */ +export interface ChannelBindingDto { + bindingId: string; + channelName: string; + workspaceId: string; + channelId: string; + logicalAgentId: string; + principals: Readonly>; +} + +export interface ChannelResponseTargetDto { + channelId: string; + threadId?: string; +} + +/** + * Stable channel-to-session route. Runtime provider, harness, model, process, + * and native runtime session identifiers are intentionally absent. + */ +export interface ChannelConversationRouteDto { + bindingId: string; + logicalAgentId: string; + conversationId: string; + channelName: string; + authorizationChannelId: string; + responseTarget: ChannelResponseTargetDto; +} + +/** Authorized adapter-to-gateway ingress after native translation. */ +export interface ChannelIngressDto { + correlationId: string; + nativeMessageId: string; + operation: ChannelOperation; + principal: ChannelAuthorizedPrincipalDto; + message: ChannelMessageDto; + route: ChannelConversationRouteDto; +} + +/** Gateway-to-adapter egress; runtime/provider identity remains gateway-internal. */ +export interface ChannelEgressDto { + correlationId: string; + message: ChannelMessageDto; + route: ChannelConversationRouteDto; +} + +export interface ChannelAdapterHealthDto { + status: ChannelAdapterStatus; + detail?: string; +} diff --git a/packages/types/src/channel/index.ts b/packages/types/src/channel/index.ts new file mode 100644 index 0000000..fb6413c --- /dev/null +++ b/packages/types/src/channel/index.ts @@ -0,0 +1,2 @@ +export * from './channel-adapter.js'; +export * from './channel.dto.js'; diff --git a/packages/types/src/chat/events.ts b/packages/types/src/chat/events.ts index 432a53d..075431a 100644 --- a/packages/types/src/chat/events.ts +++ b/packages/types/src/chat/events.ts @@ -1,3 +1,4 @@ +import type { ChannelAttachmentDto } from '../channel/index.js'; import type { CommandManifestPayload, SlashCommandApprovalResultPayload, @@ -73,6 +74,7 @@ export interface ChatMessagePayload { provider?: string; modelId?: string; agentId?: string; + attachments?: readonly ChannelAttachmentDto[]; } /** Routing decision summary included in session:info for transparency */ diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index d35b52c..3b2ab89 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,5 +1,6 @@ export const VERSION = '0.0.0'; +export * from './channel/index.js'; export * from './chat/index.js'; export * from './agent/index.js'; export * from './provider/index.js'; diff --git a/plugins/discord/README.md b/plugins/discord/README.md new file mode 100644 index 0000000..08d4dfe --- /dev/null +++ b/plugins/discord/README.md @@ -0,0 +1,67 @@ +# @mosaicstack/discord-plugin + +Official Discord channel adapter for Mosaic Stack. + +## Behavior + +- Runs independently of the bound agent's Claude, Codex, Pi, OpenCode, or future harness. +- Routes authorized untagged messages in configured agent channels and replies in-channel. +- Creates a public Discord thread when the bot is mentioned in a parent channel, or reuses the thread already attached to that same message. +- Keeps follow-ups in existing threads without requiring repeated mentions. +- Keeps `/approve` and `/stop ` on the current durable session. +- Applies guild, parent-channel, user, pairing, role, and per-minute abuse limits before thread creation or gateway dispatch. +- Authenticates to the gateway and signs ingress envelopes with the injected service token. + +## Required configuration + +| Variable | Purpose | +| --------------------------------------- | -------------------------------------------------------------------- | +| `DISCORD_BOT_TOKEN` | Discord bot credential | +| `DISCORD_SERVICE_TOKEN` | High-entropy plugin-to-gateway credential | +| `DISCORD_SERVICE_USER_ID` | Provisioned Mosaic service principal | +| `DISCORD_ALLOWED_GUILD_IDS` | Comma-separated guild allowlist | +| `DISCORD_ALLOWED_CHANNEL_IDS` | Comma-separated parent-channel allowlist | +| `DISCORD_ALLOWED_USER_IDS` | Comma-separated Discord user allowlist | +| `DISCORD_INTERACTION_BINDINGS` | JSON channel→logical-agent bindings and paired-user roles | +| `DISCORD_GATEWAY_URL` | Gateway base URL; defaults to the gateway's local development URL | +| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Authorized turns per guild/channel/user per minute; default `30` | +| `DISCORD_THREAD_RATE_LIMIT_PER_MINUTE` | Mention-thread routes per guild/channel/user per minute; default `5` | + +Supply credentials through the approved runtime secret mechanism. Never commit tokens or binding data containing secrets. + +Example binding shape (identifiers are placeholders): + +```json +[ + { + "instanceId": "interaction-agent", + "agentConfigId": "agent-config-id", + "guildId": "guild-id", + "channelId": "channel-id", + "pairedUsers": { + "discord-user-id": { + "role": "operator", + "mosaicUserId": "mosaic-user-id" + } + } + } +] +``` + +Each binding's trusted `agentConfigId` must identify a provisioned database agent configuration whose name exactly matches its `instanceId`. Roles are `viewer`, `operator`, and `admin`. `viewer` cannot send agent turns. Runtime approval and stop operations require an `admin` pairing with a provisioned `mosaicUserId`. + +The bot needs Discord permissions to view/send messages in configured channels and create/send in public threads. A channel category is not an authorization boundary; threads inherit authorization only from their configured parent text channel. + +## Shared contract + +The adapter implements `OfficialChannelAdapter` from `@mosaicstack/types`. `ChannelConversationRouteDto` carries only stable logical-agent/channel identity and a response target. Gateway durable-session and provider layers own runtime selection and handoff; Discord code must not import a harness SDK. + +## Development + +```bash +pnpm --filter @mosaicstack/types build +pnpm --filter @mosaicstack/discord-plugin typecheck +pnpm --filter @mosaicstack/discord-plugin lint +pnpm --filter @mosaicstack/discord-plugin test +pnpm --filter @mosaicstack/discord-plugin build +``` diff --git a/plugins/discord/package.json b/plugins/discord/package.json index 72a648e..cb1a96a 100644 --- a/plugins/discord/package.json +++ b/plugins/discord/package.json @@ -19,13 +19,16 @@ "dev": "tsx watch src/index.ts", "lint": "eslint src", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run --coverage", + "test:coverage": "vitest run --coverage" }, "dependencies": { + "@mosaicstack/types": "workspace:^", "discord.js": "^14.16.0", "socket.io-client": "^4.8.0" }, "devDependencies": { + "@vitest/coverage-v8": "^2.0.0", "tsx": "^4.0.0", "typescript": "^5.8.0", "vitest": "^2.0.0" diff --git a/plugins/discord/src/index.test.ts b/plugins/discord/src/index.test.ts new file mode 100644 index 0000000..8122634 --- /dev/null +++ b/plugins/discord/src/index.test.ts @@ -0,0 +1,950 @@ +import { EventEmitter } from 'node:events'; +import type { + ChannelConversationRouteDto, + ChannelEgressDto, + ChannelIngressDto, + ChannelIngressPort, +} from '@mosaicstack/types'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createDiscordIngressEnvelope, + DiscordPlugin, + parseDiscordInteractionBindings, + resolveDiscordInteractionActorId, + resolveDiscordInteractionBinding, + verifyDiscordIngressEnvelope, + type DiscordIngressEnvelope, + type DiscordInteractionRole, + type DiscordPluginConfig, +} from './index.js'; + +const SERVICE_TOKEN = 'test-service-token'; + +interface FakeDiscordMessageOptions { + id?: string; + guildId?: string; + content: string; + mentioned?: boolean; + userId?: string; + channelId?: string; + parentChannelId?: string | null; + isThread?: boolean; + hasThread?: boolean; + existingThreadId?: string; + fetchedThreadId?: string; + createdThreadId?: string; + attachments?: Map; +} + +interface FakeDiscordAttachment { + id: string; + name: string; + url: string; + contentType: string | null; + size?: number; +} + +interface FakeDiscordMessage { + id: string; + guildId: string; + channelId: string; + author: { id: string; bot: boolean }; + mentions: { has(user: { id: string }): boolean }; + content: string; + createdAt: Date; + channel: { + parentId: string | null; + isThread(): boolean; + threads?: { fetch(id: string): Promise<{ id: string }> }; + }; + attachments: Map; + hasThread: boolean; + thread: { id: string } | null; + startThread: ReturnType; +} + +interface FakeGatewaySocket extends EventEmitter { + connected: boolean; + disconnect?: ReturnType; +} + +interface FakeLifecycleClient extends EventEmitter { + user: { id: string; tag: string }; + login: ReturnType; + destroy: ReturnType; + isReady(): boolean; + guilds: { cache: Map }; + channels: { cache: Map }; +} + +interface DiscordPluginInternals { + client: { + user: { id: string }; + isReady(): boolean; + channels?: { + cache: { get(id: string): { send(options: unknown): Promise } | undefined }; + }; + }; + socket: { + connected: boolean; + emit: ReturnType; + } | null; + conversationRoutes: Map; + handleDiscordMessage(message: FakeDiscordMessage): void | Promise; + sendToDiscord(conversationId: string, text: string): Promise; +} + +function lifecyclePlugin(): { + plugin: DiscordPlugin; + socket: FakeGatewaySocket; + client: FakeLifecycleClient; +} { + const socket = Object.assign(new EventEmitter(), { + connected: false, + disconnect: vi.fn(), + }); + const client = Object.assign(new EventEmitter(), { + user: { id: 'bot-001', tag: 'mosaic-bot' }, + login: vi.fn().mockResolvedValue('token'), + destroy: vi.fn().mockResolvedValue(undefined), + isReady: (): boolean => true, + guilds: { cache: new Map() }, + channels: { cache: new Map() }, + }); + const plugin = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://gateway.invalid', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'user-001': { role: 'operator', mosaicUserId: 'mosaic-user-001' }, + }, + }, + ], + }, + { + client: client as never, + socketFactory: vi.fn().mockReturnValue(socket) as never, + }, + ); + return { plugin, socket, client }; +} + +function createPlugin( + role: DiscordInteractionRole = 'operator', + paired = true, + ingressPort?: ChannelIngressPort, + configOverrides: Partial = {}, +): { + plugin: DiscordPlugin; + internals: DiscordPluginInternals; + emit: ReturnType; +} { + const plugin = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: paired + ? { + 'user-001': { role, mosaicUserId: 'mosaic-user-001' }, + } + : {}, + }, + ], + ...configOverrides, + }, + { ingressPort }, + ); + const emit = vi.fn(); + const internals = plugin as unknown as DiscordPluginInternals; + internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => true }; + internals.socket = { connected: true, emit }; + return { plugin, internals, emit }; +} + +function fakeMessage(options: FakeDiscordMessageOptions): FakeDiscordMessage { + const channelId = options.channelId ?? 'channel-001'; + const parentChannelId = options.parentChannelId; + const existingThreadId = options.existingThreadId; + return { + id: options.id ?? 'message-001', + guildId: options.guildId ?? 'guild-001', + channelId, + author: { id: options.userId ?? 'user-001', bot: false }, + mentions: { has: (): boolean => options.mentioned ?? false }, + content: options.content, + createdAt: new Date('2026-07-14T12:00:00.000Z'), + channel: { + parentId: parentChannelId ?? null, + isThread: (): boolean => + options.isThread ?? (parentChannelId !== undefined && parentChannelId !== null), + ...(options.fetchedThreadId + ? { + threads: { + fetch: vi.fn().mockResolvedValue({ id: options.fetchedThreadId }), + }, + } + : {}), + }, + attachments: options.attachments ?? new Map(), + hasThread: options.hasThread ?? existingThreadId !== undefined, + thread: existingThreadId ? { id: existingThreadId } : null, + startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }), + }; +} + +function emittedEnvelope(emit: ReturnType): DiscordIngressEnvelope { + const call = emit.mock.calls[0] as [string, DiscordIngressEnvelope] | undefined; + expect(call?.[0]).toBe('message'); + expect(call?.[1]).toBeDefined(); + return ( + call?.[1] ?? + createDiscordIngressEnvelope( + { + correlationId: 'unreachable', + messageId: 'unreachable', + guildId: 'unreachable', + channelId: 'unreachable', + userId: 'unreachable', + conversationId: 'unreachable', + content: 'unreachable', + }, + SERVICE_TOKEN, + ) + ); +} + +afterEach((): void => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('Discord binding configuration', () => { + it('parses role-only and Mosaic-linked pairings', () => { + const bindings = parseDiscordInteractionBindings( + JSON.stringify([ + { + instanceId: 'Nova', + agentConfigId: 'agent-config-nova', + guildId: 'guild-001', + channelId: 'channel-001', + pairedUsers: { + 'legacy-user': 'operator', + 'linked-user': { role: 'admin', mosaicUserId: 'mosaic-admin-001' }, + 'linked-without-id': { role: 'operator' }, + }, + }, + ]), + ); + + expect(bindings).toHaveLength(1); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'legacy-user')).toBeNull(); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-user')).toBe('mosaic-admin-001'); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'linked-without-id')).toBeNull(); + expect(resolveDiscordInteractionActorId(bindings[0]!, 'missing-user')).toBeNull(); + expect( + resolveDiscordInteractionBinding(bindings, 'guild-001', 'channel-001', 'linked-user', 'bind'), + ).toBe(bindings[0]); + expect( + resolveDiscordInteractionBinding( + bindings, + 'guild-001', + 'channel-001', + 'linked-without-id', + 'attach', + ), + ).toBe(bindings[0]); + expect( + resolveDiscordInteractionBinding( + bindings, + 'other-guild', + 'channel-001', + 'linked-user', + 'send', + ), + ).toBeNull(); + }); + + it.each([ + ['missing value', undefined], + ['empty array', '[]'], + ['non-object binding', '[null]'], + ['missing binding fields', '[{"instanceId":"Nova"}]'], + [ + 'empty Discord user ID', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"":"operator"}}]', + ], + [ + 'invalid role-only pairing', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":"owner"}}]', + ], + [ + 'non-object pairing', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":42}}]', + ], + [ + 'invalid linked pairing', + '[{"instanceId":"Nova","guildId":"g","channelId":"c","pairedUsers":{"u":{"role":"admin","mosaicUserId":" "}}}]', + ], + ])('rejects %s', (_case: string, value: string | undefined) => { + expect(() => parseDiscordInteractionBindings(value)).toThrow(); + }); +}); + +describe('Discord ingress integrity', () => { + it.each([ + ['guild', { guildIds: ['other'], channelIds: ['channel-001'], userIds: ['user-001'] }], + ['channel', { guildIds: ['guild-001'], channelIds: ['other'], userIds: ['user-001'] }], + ['user', { guildIds: ['guild-001'], channelIds: ['channel-001'], userIds: ['other'] }], + ])('rejects an unallowlisted %s', (_field: string, allowlists) => { + const payload = { + correlationId: 'correlation-001', + messageId: 'message-001', + guildId: 'guild-001', + channelId: 'channel-001', + userId: 'user-001', + conversationId: 'Nova:discord:channel-001', + content: 'hello', + }; + const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN); + + expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, allowlists)).toBeNull(); + }); +}); + +describe('Discord adapter lifecycle', () => { + it('starts while the in-process gateway is still connecting, then reports connected', async () => { + const { plugin, socket, client } = lifecyclePlugin(); + + await plugin.start(); + + expect(client.login).toHaveBeenCalledWith('unused'); + expect(await plugin.health()).toEqual({ status: 'degraded' }); + socket.connected = true; + socket.emit('connect'); + expect(await plugin.health()).toEqual({ status: 'connected' }); + await plugin.stop(); + expect(socket.disconnect).toHaveBeenCalledOnce(); + expect(client.destroy).toHaveBeenCalledOnce(); + }); + + it('keeps running while Socket.IO reconnects after an initial gateway error', async () => { + const { plugin, socket } = lifecyclePlugin(); + const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined); + + await plugin.start(); + socket.emit('connect_error', new Error('gateway not listening yet')); + + expect(error).toHaveBeenCalledWith( + '[discord] Gateway connection error: gateway not listening yet', + ); + expect(await plugin.health()).toEqual({ status: 'degraded' }); + }); + + it('cleans up when Discord login fails', async () => { + const { plugin, socket, client } = lifecyclePlugin(); + client.login.mockRejectedValueOnce(new Error('Discord authentication rejected')); + + await expect(plugin.start()).rejects.toThrow('Discord authentication rejected'); + + expect(socket.disconnect).toHaveBeenCalledOnce(); + expect(client.destroy).toHaveBeenCalledOnce(); + }); +}); + +describe('Discord project channel provisioning', () => { + it('returns null without a configured guild or visible guild', async () => { + const { plugin } = createPlugin(); + await expect( + plugin.createProjectChannel({ id: 'project-1', name: 'Alpha' }), + ).resolves.toBeNull(); + + const { client } = lifecyclePlugin(); + const configured = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + guildId: 'missing-guild', + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [], + }, + { client: client as never }, + ); + await expect( + configured.createProjectChannel({ id: 'project-1', name: 'Alpha' }), + ).resolves.toBeNull(); + }); + + it('creates a normalized Discord project channel', async () => { + const create = vi.fn().mockResolvedValue({ id: 'created-channel-001' }); + const { client } = lifecyclePlugin(); + client.guilds.cache.set('guild-001', { channels: { create } }); + const plugin = new DiscordPlugin( + { + token: 'unused', + gatewayUrl: 'http://unused', + serviceToken: SERVICE_TOKEN, + guildId: 'guild-001', + allowedGuildIds: ['guild-001'], + allowedChannelIds: ['channel-001'], + allowedUserIds: ['user-001'], + interactionBindings: [], + }, + { client: client as never }, + ); + + await expect( + plugin.createProjectChannel({ + id: 'project-1', + name: ' Project Alpha! ', + description: 'Alpha workspace', + }), + ).resolves.toEqual({ channelId: 'created-channel-001' }); + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'mosaic-project-alpha', topic: 'Alpha workspace' }), + ); + + await plugin.createProjectChannel({ id: 'project-2', name: 'Beta' }); + expect(create).toHaveBeenLastCalledWith( + expect.objectContaining({ topic: 'Mosaic project: Beta' }), + ); + }); +}); + +function egressFor( + route: ChannelConversationRouteDto, + content = 'agent response', +): ChannelEgressDto { + return { + correlationId: 'egress-correlation-001', + route, + message: { + id: 'egress-message-001', + channelName: 'discord', + channelId: route.responseTarget.channelId, + senderId: route.logicalAgentId, + senderKind: 'agent', + content, + contentKind: 'markdown', + timestamp: '2026-07-14T12:00:00.000Z', + metadata: {}, + }, + }; +} + +describe('official Discord channel routing', () => { + it('normalizes an authorized turn through the shared channel ingress port', async () => { + const receive = vi.fn<(ingress: ChannelIngressDto) => Promise>().mockResolvedValue(); + const { internals, emit } = createPlugin('operator', true, { receive }); + internals.socket = null; + + await internals.handleDiscordMessage(fakeMessage({ content: 'normalized turn' })); + + expect(emit).not.toHaveBeenCalled(); + expect(receive).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'message.send', + principal: { + channelUserId: 'user-001', + role: 'operator', + mosaicUserId: 'mosaic-user-001', + }, + message: expect.objectContaining({ + channelName: 'discord', + channelId: 'channel-001', + content: 'normalized turn', + senderKind: 'user', + }), + route: expect.objectContaining({ + logicalAgentId: 'Nova', + conversationId: 'Nova:discord:channel-001', + }), + }), + ); + }); + + it('releases a response route when typed ingress rejects', async () => { + const receive = vi.fn().mockRejectedValue(new Error('gateway rejected ingress')); + const { internals } = createPlugin('operator', true, { receive }); + + await expect( + internals.handleDiscordMessage(fakeMessage({ content: 'rejected typed ingress' })), + ).rejects.toThrow('gateway rejected ingress'); + + expect(internals.conversationRoutes).toHaveLength(0); + }); + + it('routes an authorized untagged parent-channel message and responds in that channel', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ content: 'channel conversation' }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN); + expect(payload).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:channel-001', + content: 'channel conversation', + }); + expect(payload?.threadId).toBeUndefined(); + }); + + it('creates a thread for a mentioned parent-channel message and routes the response there', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> investigate this topic', + mentioned: true, + createdThreadId: 'thread-created-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).toHaveBeenCalledOnce(); + const payload = verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN); + expect(payload).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:thread-created-001', + content: 'investigate this topic', + threadId: 'thread-created-001', + }); + }); + + it('fetches and reuses an existing thread that is missing from the cache', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> continue uncached topic', + mentioned: true, + hasThread: true, + fetchedThreadId: 'thread-fetched-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + conversationId: 'Nova:discord:thread-fetched-001', + threadId: 'thread-fetched-001', + }); + }); + + it('delivers the agent response to the thread selected by the mentioned turn', async () => { + const { internals } = createPlugin(); + const send = vi.fn().mockResolvedValue(undefined); + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { + cache: { + get: (id: string): { send(options: unknown): Promise } | undefined => + id === 'thread-response-001' ? { send } : undefined, + }, + }, + }; + + await internals.handleDiscordMessage( + fakeMessage({ + content: '<@bot-001> threaded response', + mentioned: true, + createdThreadId: 'thread-response-001', + }), + ); + await internals.sendToDiscord('Nova:discord:thread-response-001', 'agent answer'); + + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ content: 'agent answer', enforceNonce: true }), + ); + }); + + it('reuses a thread already attached to a mentioned message instead of creating another', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> continue existing topic', + mentioned: true, + existingThreadId: 'thread-existing-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + conversationId: 'Nova:discord:thread-existing-001', + threadId: 'thread-existing-001', + }); + }); + + it('keeps an untagged follow-up inside an authorized thread without nesting threads', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: 'thread follow-up', + channelId: 'thread-001', + parentChannelId: 'channel-001', + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:thread-001', + content: 'thread follow-up', + threadId: 'thread-001', + }); + }); + + it.each([ + ['guild', { guildId: 'guild-not-allowed' }], + ['channel', { channelId: 'channel-not-allowed' }], + ['user', { userId: 'user-not-allowed' }], + ])( + 'rejects an unauthorized %s before thread creation or gateway dispatch', + async (_boundary: string, override: Partial) => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: '<@bot-001> unauthorized topic', + mentioned: true, + ...override, + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }, + ); + + it.each([ + ['parent channel', {}], + ['existing thread', { channelId: 'thread-unpaired-001', parentChannelId: 'channel-001' }], + ])( + 'rejects an allowlisted but unpaired user in %s', + async (_location: string, override: Partial) => { + const { internals, emit } = createPlugin('operator', false); + const message = fakeMessage({ content: 'unpaired message', ...override }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }, + ); + + it('rejects a paired viewer before thread creation or gateway dispatch', async () => { + const { internals, emit } = createPlugin('viewer'); + const message = fakeMessage({ + content: '<@bot-001> viewer cannot send', + mentioned: true, + }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('uses a normal channel ID rather than its category parent for authorization', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ + content: 'message from categorized channel', + parentChannelId: 'category-001', + isThread: false, + }); + + await internals.handleDiscordMessage(message); + + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + channelId: 'channel-001', + conversationId: 'Nova:discord:channel-001', + }); + }); + + it.each([ + ['parent channel', {}], + ['existing thread', { channelId: 'thread-attachment-001', parentChannelId: 'channel-001' }], + ])( + 'preserves an attachment-only turn in an authorized %s', + async (_location: string, override: Partial) => { + const { internals, emit } = createPlugin(); + const attachments = new Map([ + [ + 'attachment-001', + { + id: 'attachment-001', + name: 'diagram.png', + url: 'https://cdn.example.invalid/diagram.png', + contentType: 'image/png', + size: 4_096, + }, + ], + ]); + + await internals.handleDiscordMessage(fakeMessage({ content: '', attachments, ...override })); + + expect(verifyDiscordIngressEnvelope(emittedEnvelope(emit), SERVICE_TOKEN)).toMatchObject({ + content: '', + attachments: [ + { + id: 'attachment-001', + name: 'diagram.png', + contentType: 'image/png', + sizeBytes: 4_096, + }, + ], + }); + }, + ); + + it('does not dispatch when Discord cannot create the requested thread', async () => { + const { internals, emit } = createPlugin(); + const message = fakeMessage({ content: '<@bot-001> new topic', mentioned: true }); + message.startThread.mockRejectedValueOnce(new Error('missing thread permission')); + + await expect(internals.handleDiscordMessage(message)).rejects.toThrow( + 'missing thread permission', + ); + expect(emit).not.toHaveBeenCalled(); + }); + + it.each(['/approve', '/stop approval-001'])( + 'rejects operator use of privileged command %s before gateway dispatch', + async (command: string) => { + const { internals, emit } = createPlugin('operator'); + const message = fakeMessage({ content: `<@bot-001> ${command}`, mentioned: true }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }, + ); + + it('keeps runtime control commands on the current durable session', async () => { + const { internals, emit } = createPlugin('admin'); + const message = fakeMessage({ content: '<@bot-001> /approve', mentioned: true }); + + await internals.handleDiscordMessage(message); + + expect(message.startThread).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledWith('discord:approve', expect.any(Object)); + }); + + it('keeps the stable conversation address independent of a runtime harness', async () => { + const first = createPlugin(); + const second = createPlugin(); + + await first.internals.handleDiscordMessage( + fakeMessage({ id: 'message-claude', content: 'before runtime handoff' }), + ); + await second.internals.handleDiscordMessage( + fakeMessage({ id: 'message-pi', content: 'after runtime handoff' }), + ); + + const firstPayload = verifyDiscordIngressEnvelope(emittedEnvelope(first.emit), SERVICE_TOKEN); + const secondPayload = verifyDiscordIngressEnvelope(emittedEnvelope(second.emit), SERVICE_TOKEN); + expect(firstPayload?.conversationId).toBe('Nova:discord:channel-001'); + expect(secondPayload?.conversationId).toBe(firstPayload?.conversationId); + }); + + it('rate-limits authorized turns before thread creation or dispatch', async () => { + const { internals, emit } = createPlugin('operator', true, undefined, { + messageRateLimitPerMinute: 1, + threadRateLimitPerMinute: 1, + }); + const error = vi.spyOn(console, 'error').mockImplementation((): void => undefined); + const first = fakeMessage({ id: 'rate-first', content: 'first turn' }); + const second = fakeMessage({ + id: 'rate-second', + content: '<@bot-001> second turn', + mentioned: true, + }); + + await internals.handleDiscordMessage(first); + await internals.handleDiscordMessage(second); + + expect(emit).toHaveBeenCalledOnce(); + expect(second.startThread).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Message rate limit reached')); + }); + + it('applies a stricter mention-thread rate limit before Discord side effects', async () => { + const { internals, emit } = createPlugin('operator', true, undefined, { + messageRateLimitPerMinute: 10, + threadRateLimitPerMinute: 1, + }); + vi.spyOn(console, 'error').mockImplementation((): void => undefined); + const first = fakeMessage({ id: 'thread-rate-first', content: 'first topic', mentioned: true }); + const second = fakeMessage({ + id: 'thread-rate-second', + content: 'second topic', + mentioned: true, + }); + + await internals.handleDiscordMessage(first); + await internals.handleDiscordMessage(second); + + expect(first.startThread).toHaveBeenCalledOnce(); + expect(second.startThread).not.toHaveBeenCalled(); + expect(emit).toHaveBeenCalledOnce(); + }); + + it('rejects unconfigured egress and handles missing or failed Discord destinations', async () => { + const { plugin, internals } = createPlugin(); + const route: ChannelConversationRouteDto = { + bindingId: 'guild-001:channel-001:Nova', + logicalAgentId: 'Nova', + conversationId: 'Nova:discord:channel-001', + channelName: 'discord', + authorizationChannelId: 'channel-001', + responseTarget: { channelId: 'channel-001' }, + }; + + await expect( + plugin.send( + egressFor({ + ...route, + bindingId: 'forged-binding', + }), + ), + ).rejects.toMatchObject({ code: 'invalid_route' }); + await expect( + plugin.send( + egressFor({ + ...route, + conversationId: 'Nova:discord:unrelated-conversation', + }), + ), + ).rejects.toMatchObject({ code: 'invalid_route' }); + await expect( + plugin.send({ + ...egressFor(route), + message: { ...egressFor(route).message, channelId: 'other-channel' }, + }), + ).rejects.toMatchObject({ code: 'invalid_route' }); + + const forgedSend = vi.fn().mockResolvedValue(undefined); + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { + cache: { + get: (): { send(options: unknown): Promise } => ({ send: forgedSend }), + }, + }, + }; + await expect( + plugin.send( + egressFor({ + ...route, + conversationId: 'Nova:discord:forged-channel', + responseTarget: { channelId: 'forged-channel', threadId: 'forged-channel' }, + }), + ), + ).rejects.toMatchObject({ code: 'invalid_route' }); + expect(forgedSend).not.toHaveBeenCalled(); + + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { cache: { get: (): undefined => undefined } }, + }; + await expect(plugin.send(egressFor(route))).rejects.toMatchObject({ + code: 'destination_unavailable', + }); + await expect( + internals.sendToDiscord('missing-conversation', 'missing route'), + ).rejects.toMatchObject({ code: 'invalid_route' }); + + vi.useFakeTimers(); + const send = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Discord unavailable'), { status: 503 })); + internals.client.channels = { + cache: { get: (): { send(options: unknown): Promise } => ({ send }) }, + }; + const delivery = plugin.send(egressFor(route)); + const rejection = expect(delivery).rejects.toMatchObject({ + code: 'delivery_failed', + retryable: true, + }); + await vi.runAllTimersAsync(); + await rejection; + + expect(send).toHaveBeenCalledTimes(3); + expect(new Set(send.mock.calls.map(([options]) => options.nonce)).size).toBe(1); + expect(send).toHaveBeenCalledWith( + expect.objectContaining({ enforceNonce: true, content: 'agent response' }), + ); + + const permanentSend = vi + .fn() + .mockRejectedValue(Object.assign(new Error('Discord forbidden'), { status: 403 })); + internals.client.channels = { + cache: { + get: (): { send(options: unknown): Promise } => ({ send: permanentSend }), + }, + }; + await expect(plugin.send(egressFor(route))).rejects.toMatchObject({ + code: 'delivery_failed', + retryable: false, + }); + expect(permanentSend).toHaveBeenCalledOnce(); + }); + + it('chunks long egress responses at Discord-safe boundaries', async () => { + const { plugin, internals } = createPlugin(); + const send = vi.fn().mockResolvedValue(undefined); + internals.client = { + user: { id: 'bot-001' }, + isReady: (): boolean => true, + channels: { + cache: { get: (): { send(options: unknown): Promise } => ({ send }) }, + }, + }; + const route: ChannelConversationRouteDto = { + bindingId: 'guild-001:channel-001:Nova', + logicalAgentId: 'Nova', + conversationId: 'Nova:discord:channel-001', + channelName: 'discord', + authorizationChannelId: 'channel-001', + responseTarget: { channelId: 'channel-001' }, + }; + + await plugin.send(egressFor(route, `${'a'.repeat(1_500)}\n${'b'.repeat(1_500)}`)); + + expect(send).toHaveBeenCalledTimes(2); + }); + + it('reports channel adapter health without exposing runtime-provider state', async () => { + const { plugin, internals } = createPlugin(); + + expect(await plugin.health()).toEqual({ status: 'connected' }); + internals.socket = { connected: false, emit: vi.fn() }; + expect(await plugin.health()).toEqual({ status: 'degraded' }); + internals.client = { user: { id: 'bot-001' }, isReady: (): boolean => false }; + expect(await plugin.health()).toEqual({ status: 'disconnected' }); + internals.socket = { connected: true, emit: vi.fn() }; + expect(await plugin.health()).toEqual({ status: 'degraded' }); + }); +}); diff --git a/plugins/discord/src/index.ts b/plugins/discord/src/index.ts index 15d4997..e7b88c3 100644 --- a/plugins/discord/src/index.ts +++ b/plugins/discord/src/index.ts @@ -1,10 +1,38 @@ -import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; -import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js'; +import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; +import { ChannelDeliveryError } from '@mosaicstack/types'; +import type { + ChannelAdapterHealthDto, + ChannelAuthorizedPrincipalDto, + ChannelConversationRouteDto, + ChannelEgressDto, + ChannelEgressPort, + ChannelIngressDto, + ChannelIngressPort, + ChannelMessageDto, + OfficialChannelAdapter, +} from '@mosaicstack/types'; +import { + ChannelType, + Client, + GatewayIntentBits, + ThreadAutoArchiveDuration, + type Message as DiscordMessage, +} from 'discord.js'; import { io, type Socket } from 'socket.io-client'; +export interface DiscordPluginDependencies { + ingressPort?: ChannelIngressPort; + client?: Client; + socketFactory?: (url: string, options: Parameters[1]) => Socket; +} + export interface DiscordPluginConfig { token: string; gatewayUrl: string; + /** Maximum authorized turns per Discord user/channel per minute. */ + messageRateLimitPerMinute?: number; + /** Maximum mention-triggered thread routes per Discord user/channel per minute. */ + threadRateLimitPerMinute?: number; /** Shared service credential injected by the approved secret mechanism. */ serviceToken: string; /** Which guild to bind to (single-guild only for v0.1.0). */ @@ -30,13 +58,23 @@ export interface DiscordInteractionUserBinding { export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding; export interface DiscordInteractionBinding { + /** Stable logical agent identity used in channel conversation routes. */ instanceId: string; + /** Trusted gateway database agent-config ID selected for this binding. */ + agentConfigId: string; guildId: string; channelId: string; /** Pairing roster keyed by Discord user ID. */ pairedUsers: Readonly>; } +const DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE = 30; +const DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE = 5; +const RATE_LIMIT_WINDOW_MS = 60_000; +const DELIVERY_MAX_ATTEMPTS = 3; +const DELIVERY_RETRY_BASE_MS = 50; +const MAX_CONVERSATION_ROUTES = 1_000; + const operationRoles: Readonly< Record > = { @@ -90,6 +128,7 @@ export function parseDiscordInteractionBindings( const candidate = binding as Partial; if ( !candidate.instanceId || + !candidate.agentConfigId || !candidate.guildId || !candidate.channelId || !candidate.pairedUsers || @@ -130,6 +169,7 @@ export function parseDiscordInteractionBindings( ) as Record; return { instanceId: candidate.instanceId, + agentConfigId: candidate.agentConfigId, guildId: candidate.guildId, channelId: candidate.channelId, pairedUsers, @@ -154,6 +194,7 @@ export interface DiscordAttachment { name: string; url: string; contentType: string | null; + sizeBytes?: number; } export interface DiscordIngressEnvelope { @@ -229,27 +270,37 @@ export function verifyDiscordIngressEnvelope( return envelope.payload; } -export class DiscordPlugin { +export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort { + readonly name = 'discord'; + private client: Client; private socket: Socket | null = null; - /** Map Discord channel ID → Mosaic conversation ID. */ - private channelConversations = new Map(); + /** Bounded last-authorized routes for response-target egress validation. */ + private conversationRoutes = new Map(); /** Track in-flight responses to avoid duplicate streaming. */ private pendingResponses = new Map(); + private readonly messageRateWindows = new Map(); + private readonly threadRateWindows = new Map(); - constructor(private readonly config: DiscordPluginConfig) { - this.client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - GatewayIntentBits.DirectMessages, - ], - }); + constructor( + private readonly config: DiscordPluginConfig, + private readonly dependencies: DiscordPluginDependencies = {}, + ) { + this.client = + dependencies.client ?? + new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + }); } async start(): Promise { - this.socket = io(`${this.config.gatewayUrl}/chat`, { + const socketFactory = this.dependencies.socketFactory ?? io; + this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, { auth: { discordServiceToken: this.config.serviceToken }, transports: ['websocket'], }); @@ -276,11 +327,13 @@ export class DiscordPlugin { this.socket.on('agent:end', (data: { conversationId: string }) => { const text = this.pendingResponses.get(data.conversationId); + this.pendingResponses.delete(data.conversationId); if (text) { - this.pendingResponses.delete(data.conversationId); - this.sendToDiscord(data.conversationId, text).catch((err: unknown) => { + this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => { console.error(`[discord] Error sending response for ${data.conversationId}:`, err); }); + } else { + this.conversationRoutes.delete(data.conversationId); } }); @@ -288,15 +341,33 @@ export class DiscordPlugin { this.pendingResponses.set(data.conversationId, ''); }); - this.client.on('messageCreate', (message: DiscordMessage) => - this.handleDiscordMessage(message), - ); + this.socket.on('error', (data: { conversationId?: unknown }) => { + if (typeof data.conversationId !== 'string') return; + this.pendingResponses.delete(data.conversationId); + this.conversationRoutes.delete(data.conversationId); + }); + + this.client.on('messageCreate', (message: DiscordMessage) => { + void Promise.resolve(this.handleDiscordMessage(message)).catch((error: unknown): void => { + const errorName = error instanceof Error ? error.name : 'UnknownError'; + console.error( + `[discord] Message routing failed. channel=${message.channelId} message=${message.id} error=${errorName}`, + ); + }); + }); this.client.on('ready', () => { console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); }); - await this.client.login(this.config.token); + try { + await this.client.login(this.config.token); + } catch (error: unknown) { + this.socket.disconnect(); + this.socket = null; + await this.client.destroy(); + throw error; + } } async stop(): Promise { @@ -304,6 +375,14 @@ export class DiscordPlugin { await this.client.destroy(); } + async health(): Promise { + const discordReady = this.client.isReady(); + const gatewayConnected = this.socket?.connected === true; + if (discordReady && gatewayConnected) return { status: 'connected' }; + if (discordReady || gatewayConnected) return { status: 'degraded' }; + return { status: 'disconnected' }; + } + async createProjectChannel(project: { id: string; name: string; @@ -325,77 +404,224 @@ export class DiscordPlugin { topic: project.description ?? `Mosaic project: ${project.name}`, }); - this.channelConversations.set(channel.id, `discord-${channel.id}`); + // A project channel has no logical-agent conversation until a configured + // binding authorizes a message. Do not seed a legacy channel-only key. return { channelId: channel.id }; } - private handleDiscordMessage(message: DiscordMessage): void { - if (message.author.bot || !this.client.user) return; - if (!message.guildId || !this.isAllowedMessage(message)) return; + private handleDiscordMessage(message: DiscordMessage): void | Promise { + if (message.author.bot || !this.client.user || !message.guildId) return; + + const authorizationChannelId = this.authorizationChannelId(message); + if (!this.isAllowedMessage(message, authorizationChannelId)) return; const isMention = message.mentions.has(this.client.user); - if (!isMention) return; + const content = isMention + ? message.content.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '').trim() + : message.content.trim(); + if (!content && message.attachments.size === 0) return; + const operation = this.interactionOperation(content); + const binding = resolveDiscordInteractionBinding( + this.config.interactionBindings ?? [], + message.guildId, + authorizationChannelId, + message.author.id, + operation, + ); + // Pairing and operation-specific role checks happen before thread creation + // or any gateway dispatch, including privileged control events. + if (!binding) return; - const content = message.content - .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '') - .trim(); - if (!content) return; - if (!this.socket?.connected) { + const rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`; + if ( + !this.consumeRateLimit( + this.messageRateWindows, + rateKey, + this.config.messageRateLimitPerMinute ?? DEFAULT_MESSAGE_RATE_LIMIT_PER_MINUTE, + ) + ) { + console.error( + `[discord] Message rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`, + ); + return; + } + + const createThread = isMention && operation === 'send'; + if ( + createThread && + !this.consumeRateLimit( + this.threadRateWindows, + rateKey, + this.config.threadRateLimitPerMinute ?? DEFAULT_THREAD_RATE_LIMIT_PER_MINUTE, + ) + ) { + console.error( + `[discord] Thread rate limit reached. guild=${message.guildId} channel=${authorizationChannelId} user=${message.author.id}`, + ); + return; + } + + if (!this.dependencies.ingressPort && !this.socket?.connected) { console.error( `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, ); return; } - const channelId = message.channelId; - const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; - const bindingChannelId = parentChannelId ?? channelId; - const binding = resolveDiscordInteractionBinding( - this.config.interactionBindings ?? [], - message.guildId, - bindingChannelId, - message.author.id, - 'send', + // Approval/stop commands act on the current durable session. They remain + // at the current channel/thread target rather than creating a new topic. + const route = this.resolveConversationRoute( + message, + binding, + authorizationChannelId, + createThread, ); - if (!binding) return; - const conversationId = - this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`; - this.channelConversations.set(channelId, conversationId); + if (route instanceof Promise) { + return route.then((resolved: ChannelConversationRouteDto): void | Promise => + this.dispatchDiscordIngress(message, content, operation, binding, resolved), + ); + } + return this.dispatchDiscordIngress(message, content, operation, binding, route); + } + private dispatchDiscordIngress( + message: DiscordMessage, + content: string, + operation: 'send' | 'approve' | 'stop', + binding: DiscordInteractionBinding, + route: ChannelConversationRouteDto, + ): void | Promise { + const guildId = message.guildId; + if (!guildId) return; + if (operation === 'send') this.rememberConversationRoute(route); + const correlationId = randomUUID(); + const ingress: ChannelIngressDto = { + correlationId, + nativeMessageId: message.id, + operation: + operation === 'approve' + ? 'approval.create' + : operation === 'stop' + ? 'session.stop' + : 'message.send', + principal: this.authorizedPrincipal(binding, message.author.id), + message: this.channelMessage(message, content, route), + route, + }; + if (this.dependencies.ingressPort) { + return this.dependencies.ingressPort.receive(ingress).catch((error: unknown) => { + if (operation === 'send') this.conversationRoutes.delete(route.conversationId); + throw error; + }); + } + const socket = this.socket; + if (!socket?.connected) { + console.error( + `[discord] Cannot dispatch routed message: gateway disconnected. channel=${message.channelId} message=${message.id}`, + ); + return; + } + this.emitDiscordIngress(socket, ingress); + } + + private authorizedPrincipal( + binding: DiscordInteractionBinding, + channelUserId: string, + ): ChannelAuthorizedPrincipalDto { + const pairing = binding.pairedUsers[channelUserId]; + if (!pairing) throw new Error('Authorized Discord pairing is unavailable'); + if (typeof pairing === 'string') return { channelUserId, role: pairing }; + return { + channelUserId, + role: pairing.role, + ...(pairing.mosaicUserId ? { mosaicUserId: pairing.mosaicUserId } : {}), + }; + } + + private channelMessage( + message: DiscordMessage, + content: string, + route: ChannelConversationRouteDto, + ): ChannelMessageDto { + const attachments = Array.from(message.attachments.values()).map((attachment) => ({ + id: attachment.id, + name: attachment.name, + url: attachment.url, + mimeType: attachment.contentType, + sizeBytes: attachment.size, + })); + const firstContentType = attachments[0]?.mimeType; + return { + id: randomUUID(), + channelName: this.name, + channelId: route.responseTarget.channelId, + senderId: message.author.id, + senderKind: 'user', + content, + contentKind: + content.length > 0 ? 'markdown' : firstContentType?.startsWith('image/') ? 'image' : 'file', + timestamp: + message.createdAt instanceof Date + ? message.createdAt.toISOString() + : new Date().toISOString(), + ...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}), + ...(attachments.length > 0 ? { attachments } : {}), + metadata: { + channelMessageId: message.id, + guildId: message.guildId ?? '', + }, + }; + } + + private emitDiscordIngress(socket: Socket, ingress: ChannelIngressDto): void { const envelope = createDiscordIngressEnvelope( { - correlationId: randomUUID(), - messageId: message.id, - guildId: message.guildId, - channelId: bindingChannelId, - userId: message.author.id, - conversationId, - content, - threadId: parentChannelId ? channelId : undefined, - attachments: Array.from(message.attachments.values()).map((attachment) => ({ + correlationId: ingress.correlationId, + messageId: ingress.nativeMessageId, + guildId: String(ingress.message.metadata['guildId'] ?? ''), + channelId: ingress.route.authorizationChannelId, + userId: ingress.principal.channelUserId, + conversationId: ingress.route.conversationId, + content: ingress.message.content, + ...(ingress.route.responseTarget.threadId + ? { threadId: ingress.route.responseTarget.threadId } + : {}), + attachments: ingress.message.attachments?.map((attachment) => ({ id: attachment.id, name: attachment.name, url: attachment.url, - contentType: attachment.contentType, + contentType: attachment.mimeType, + ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), })), }, this.config.serviceToken, ); - this.socket.emit( - /^\/approve$/i.test(content) + socket.emit( + ingress.operation === 'approval.create' ? 'discord:approve' - : content.startsWith('/stop ') + : ingress.operation === 'session.stop' ? 'discord:stop' : 'message', envelope, ); } - private isAllowedMessage(message: DiscordMessage): boolean { + private authorizationChannelId(message: DiscordMessage): string { + // A normal guild channel can itself have a category parent. Only Discord + // threads inherit authorization from a configured parent text channel. + const channel = message.channel as DiscordMessage['channel'] & { + isThread?: () => boolean; + parentId?: string | null; + }; + const isThread = + typeof channel.isThread === 'function' + ? channel.isThread() + : channel.parentId !== undefined && channel.parentId !== null; + return isThread && channel.parentId ? channel.parentId : message.channelId; + } + + private isAllowedMessage(message: DiscordMessage, authorizationChannelId: string): boolean { const guildId = message.guildId; - const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null; - // Threads inherit their authorization boundary from their configured parent. - const authorizationChannelId = parentChannelId ?? message.channelId; return ( guildId !== null && includesId(this.config.allowedGuildIds, guildId) && @@ -404,31 +630,282 @@ export class DiscordPlugin { ); } - private async sendToDiscord(conversationId: string, text: string): Promise { - const channelId = Array.from(this.channelConversations.entries()).find( - ([, convId]) => convId === conversationId, - )?.[0]; - - if (!channelId) { - console.error(`[discord] No channel found for conversation ${conversationId}`); - return; + private resolveConversationRoute( + message: DiscordMessage, + binding: DiscordInteractionBinding, + authorizationChannelId: string, + createThread: boolean, + ): ChannelConversationRouteDto | Promise { + if (authorizationChannelId !== message.channelId) { + return this.createConversationRoute( + binding, + authorizationChannelId, + message.channelId, + message.channelId, + ); + } + if (!createThread) { + return this.createConversationRoute(binding, authorizationChannelId, message.channelId); } + if (message.hasThread) { + const cachedThread = message.thread; + if (cachedThread) { + return this.createConversationRoute( + binding, + authorizationChannelId, + cachedThread.id, + cachedThread.id, + ); + } + if (!('threads' in message.channel)) { + return Promise.reject(new Error('Existing Discord thread manager is unavailable')); + } + return message.channel.threads + .fetch(message.id) + .then((thread): ChannelConversationRouteDto => { + if (!thread) throw new Error('Existing Discord thread is unavailable'); + return this.createConversationRoute( + binding, + authorizationChannelId, + thread.id, + thread.id, + ); + }); + } + return message + .startThread({ + name: `Mosaic conversation ${message.id.slice(-8)}`, + autoArchiveDuration: ThreadAutoArchiveDuration.OneHour, + reason: 'Authorized Mosaic mention', + }) + .then( + (thread): ChannelConversationRouteDto => + this.createConversationRoute(binding, authorizationChannelId, thread.id, thread.id), + ); + } + + private createConversationRoute( + binding: DiscordInteractionBinding, + authorizationChannelId: string, + responseChannelId: string, + threadId?: string, + ): ChannelConversationRouteDto { + // Recompute from configuration on every turn so a stale in-memory map can + // never carry a channel-only or differently bound agent identity forward. + const conversationId = `${binding.instanceId}:discord:${responseChannelId}`; + return { + bindingId: `${binding.guildId}:${binding.channelId}:${binding.instanceId}`, + logicalAgentId: binding.instanceId, + conversationId, + channelName: this.name, + authorizationChannelId, + responseTarget: { + channelId: responseChannelId, + ...(threadId ? { threadId } : {}), + }, + }; + } + + private consumeRateLimit( + windows: Map, + key: string, + limit: number, + now = Date.now(), + ): boolean { + const active = (windows.get(key) ?? []).filter( + (timestamp: number): boolean => now - timestamp < RATE_LIMIT_WINDOW_MS, + ); + if (active.length >= Math.max(1, limit)) { + windows.set(key, active); + return false; + } + active.push(now); + windows.set(key, active); + return true; + } + + private interactionOperation(content: string): 'send' | 'approve' | 'stop' { + if (/^\/approve$/i.test(content)) return 'approve'; + if (/^\/stop\s+\S+/i.test(content)) return 'stop'; + return 'send'; + } + + async send(egress: ChannelEgressDto): Promise { + if (!this.isConfiguredRoute(egress.route) || !this.isMessageAlignedWithRoute(egress)) { + throw new ChannelDeliveryError( + 'invalid_route', + `Discord egress route is not authorized for conversation ${egress.route.conversationId}`, + ); + } + const channelId = egress.route.responseTarget.channelId; const channel = this.client.channels.cache.get(channelId); if (!channel || !('send' in channel)) { - console.error( - `[discord] Channel ${channelId} not sendable for conversation ${conversationId}`, + throw new ChannelDeliveryError( + 'destination_unavailable', + `Discord destination is unavailable for conversation ${egress.route.conversationId}`, ); - return; } - for (const chunk of this.chunkText(text, 1900)) { + const chunks = this.chunkText(egress.message.content, 1900); + for (const [chunkIndex, chunk] of chunks.entries()) { + await this.sendChunkWithRetry( + channel as { + send(options: { content: string; nonce: string; enforceNonce: true }): Promise; + }, + chunk, + egress.correlationId, + chunkIndex, + egress.route.conversationId, + ); + } + } + + private async sendChunkWithRetry( + channel: { + send(options: { content: string; nonce: string; enforceNonce: true }): Promise; + }, + chunk: string, + correlationId: string, + chunkIndex: number, + conversationId: string, + ): Promise { + const nonce = createHash('sha256') + .update(`${correlationId}:${chunkIndex}`) + .digest('hex') + .slice(0, 25); + let lastError: unknown; + for (let attempt = 1; attempt <= DELIVERY_MAX_ATTEMPTS; attempt += 1) { try { - await (channel as { send: (content: string) => Promise }).send(chunk); - } catch (err: unknown) { - console.error(`[discord] Failed to send message to channel ${channelId}:`, err); + await channel.send({ content: chunk, nonce, enforceNonce: true }); + return; + } catch (error: unknown) { + lastError = error; + const retryable = this.isTransientDeliveryError(error); + if (!retryable) { + throw new ChannelDeliveryError( + 'delivery_failed', + `Discord delivery failed for conversation ${conversationId}`, + false, + { cause: error }, + ); + } + if (attempt < DELIVERY_MAX_ATTEMPTS) { + await new Promise((resolve): void => { + setTimeout(resolve, DELIVERY_RETRY_BASE_MS * 2 ** (attempt - 1)); + }); + } } } + throw new ChannelDeliveryError( + 'delivery_failed', + `Discord delivery failed for conversation ${conversationId}`, + true, + { cause: lastError }, + ); + } + + private isTransientDeliveryError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const candidate = error as { status?: unknown; code?: unknown }; + if ( + typeof candidate.status === 'number' && + (candidate.status === 429 || candidate.status >= 500) + ) { + return true; + } + return ( + typeof candidate.code === 'string' && + ['ECONNRESET', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'].includes(candidate.code) + ); + } + + private async sendAgentResponse(conversationId: string, text: string): Promise { + const route = this.conversationRoutes.get(conversationId); + if (!route) { + throw new ChannelDeliveryError( + 'invalid_route', + `Discord response route is unavailable for conversation ${conversationId}`, + ); + } + try { + await this.send({ + correlationId: randomUUID(), + route, + message: { + id: randomUUID(), + channelName: this.name, + channelId: route.responseTarget.channelId, + senderId: route.logicalAgentId, + senderKind: 'agent', + content: text, + contentKind: 'markdown', + timestamp: new Date().toISOString(), + ...(route.responseTarget.threadId ? { threadId: route.responseTarget.threadId } : {}), + metadata: {}, + }, + }); + } finally { + this.conversationRoutes.delete(conversationId); + } + } + + /** Compatibility wrapper while Socket.IO agent events carry only conversation ID. */ + private async sendToDiscord(conversationId: string, text: string): Promise { + await this.sendAgentResponse(conversationId, text); + } + + private rememberConversationRoute(route: ChannelConversationRouteDto): void { + if ( + !this.conversationRoutes.has(route.conversationId) && + this.conversationRoutes.size >= MAX_CONVERSATION_ROUTES + ) { + throw new ChannelDeliveryError( + 'delivery_failed', + 'Discord has reached its active response-route limit', + true, + ); + } + this.conversationRoutes.set(route.conversationId, route); + } + + private isConfiguredRoute(route: ChannelConversationRouteDto): boolean { + if ( + route.channelName !== this.name || + route.conversationId !== + `${route.logicalAgentId}:${this.name}:${route.responseTarget.channelId}` + ) { + return false; + } + const bindingMatches = (this.config.interactionBindings ?? []).some( + (binding): boolean => + route.bindingId === `${binding.guildId}:${binding.channelId}:${binding.instanceId}` && + route.logicalAgentId === binding.instanceId && + route.authorizationChannelId === binding.channelId, + ); + if (!bindingMatches) return false; + if ( + route.responseTarget.channelId === route.authorizationChannelId && + route.responseTarget.threadId === undefined + ) { + return true; + } + const observed = this.conversationRoutes.get(route.conversationId); + return ( + observed?.bindingId === route.bindingId && + observed.logicalAgentId === route.logicalAgentId && + observed.authorizationChannelId === route.authorizationChannelId && + observed.responseTarget.channelId === route.responseTarget.channelId && + observed.responseTarget.threadId === route.responseTarget.threadId + ); + } + + private isMessageAlignedWithRoute(egress: ChannelEgressDto): boolean { + return ( + egress.message.channelName === this.name && + egress.message.channelId === egress.route.responseTarget.channelId && + egress.message.threadId === egress.route.responseTarget.threadId + ); } private chunkText(text: string, maxLength: number): string[] { diff --git a/plugins/discord/vitest.config.ts b/plugins/discord/vitest.config.ts index 8e730d5..ee8101c 100644 --- a/plugins/discord/vitest.config.ts +++ b/plugins/discord/vitest.config.ts @@ -4,5 +4,15 @@ export default defineConfig({ test: { globals: true, environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + thresholds: { + lines: 85, + functions: 85, + branches: 85, + statements: 85, + }, + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5124acd..71aeab5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -732,6 +732,9 @@ importers: plugins/discord: dependencies: + '@mosaicstack/types': + specifier: workspace:^ + version: link:../../packages/types discord.js: specifier: ^14.16.0 version: 14.25.1 @@ -739,6 +742,9 @@ importers: specifier: ^4.8.0 version: 4.8.3 devDependencies: + '@vitest/coverage-v8': + specifier: ^2.0.0 + version: 2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1)) tsx: specifier: ^4.0.0 version: 4.21.0 @@ -1169,11 +1175,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -2160,47 +2166,57 @@ packages: '@mariozechner/pi-agent-core@0.63.1': resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward '@mariozechner/pi-agent-core@0.63.2': resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward '@mariozechner/pi-agent-core@0.65.0': resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-agent-core instead going forward '@mariozechner/pi-ai@0.63.1': resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true '@mariozechner/pi-ai@0.63.2': resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true '@mariozechner/pi-ai@0.65.0': resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-ai instead going forward hasBin: true '@mariozechner/pi-coding-agent@0.63.1': resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==} engines: {node: '>=20.6.0'} + deprecated: please use @earendil-works/pi-coding-agent instead going forward hasBin: true '@mariozechner/pi-coding-agent@0.65.0': resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==} engines: {node: '>=20.6.0'} + deprecated: please use @earendil-works/pi-coding-agent instead going forward hasBin: true '@mariozechner/pi-tui@0.63.1': resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-tui instead going forward '@mariozechner/pi-tui@0.65.0': resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==} engines: {node: '>=20.0.0'} + deprecated: please use @earendil-works/pi-tui instead going forward '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} @@ -3915,6 +3931,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitest/coverage-v8@2.1.9': resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} @@ -4095,6 +4112,7 @@ packages: basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} + deprecated: Security vulnerability fixed in 5.2.1, please upgrade better-auth@1.5.5: resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==} @@ -7087,6 +7105,7 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true validator@13.15.26: @@ -10977,6 +10996,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@24.12.0)(jsdom@29.0.0(@noble/hashes@2.0.1))(lightningcss@1.31.1) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9