Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
66afa325fe docs(mos): preserve Option 2 qualification evidence
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
2026-07-14 13:53:48 -05:00
32 changed files with 761 additions and 2914 deletions

View File

@@ -72,7 +72,6 @@ describe('interaction Discord/CLI durable-session integration', () => {
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1', guildId: 'guild-1',
channelId: 'channel-1', channelId: 'channel-1',
pairedUsers: { pairedUsers: {
@@ -134,7 +133,6 @@ describe('interaction Discord/CLI durable-session integration', () => {
interactionBindings: [ interactionBindings: [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-1', guildId: 'guild-1',
channelId: 'channel-1', channelId: 'channel-1',
pairedUsers: { pairedUsers: {

View File

@@ -115,18 +115,6 @@ describe('AgentService owner/tenant scope enforcement', () => {
).rejects.toBeInstanceOf(ForbiddenException); ).rejects.toBeInstanceOf(ForbiddenException);
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE); await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt'); 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( await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
ForbiddenException, ForbiddenException,

View File

@@ -15,7 +15,6 @@ import {
type ToolDefinition, type ToolDefinition,
} from '@mariozechner/pi-coding-agent'; } from '@mariozechner/pi-coding-agent';
import type { Brain } from '@mosaicstack/brain'; import type { Brain } from '@mosaicstack/brain';
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory'; import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
import { BRAIN } from '../brain/brain.tokens.js'; import { BRAIN } from '../brain/brain.tokens.js';
import { MEMORY } from '../memory/memory.tokens.js'; import { MEMORY } from '../memory/memory.tokens.js';
@@ -44,8 +43,6 @@ export interface ConversationHistoryMessage {
role: 'user' | 'assistant' | 'system'; role: 'user' | 'assistant' | 'system';
content: string; content: string;
createdAt: Date; createdAt: Date;
/** Validated, URI-referenced channel attachments preserved on session resume. */
attachments?: readonly ChannelAttachmentDto[];
} }
export interface AgentSessionOptions { export interface AgentSessionOptions {
@@ -431,7 +428,7 @@ export class AgentService implements OnModuleDestroy {
const formatMessage = (msg: ConversationHistoryMessage): string => { const formatMessage = (msg: ConversationHistoryMessage): string => {
const roleLabel = const roleLabel =
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
return `**${roleLabel}:** ${msg.content}${this.attachmentContext(msg.attachments ?? [])}`; return `**${roleLabel}:** ${msg.content}`;
}; };
const formatted = history.map((msg) => formatMessage(msg)); const formatted = history.map((msg) => formatMessage(msg));
@@ -490,21 +487,6 @@ export class AgentService implements OnModuleDestroy {
return result; 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) { private resolveModel(options?: AgentSessionOptions) {
if (!options?.provider && !options?.modelId) { if (!options?.provider && !options?.modelId) {
return this.providerService.getDefaultModel() ?? null; return this.providerService.getDefaultModel() ?? null;
@@ -691,19 +673,7 @@ export class AgentService implements OnModuleDestroy {
session.channels.delete(channel); session.channels.delete(channel);
} }
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void>; async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] | undefined,
): Promise<void>;
async prompt(
sessionId: string,
message: string,
scope: ActorTenantScope,
attachments: readonly ChannelAttachmentDto[] = [],
): Promise<void> {
const session = this.sessions.get(sessionId); const session = this.sessions.get(sessionId);
if (!session) { if (!session) {
throw new Error(`No agent session found: ${sessionId}`); throw new Error(`No agent session found: ${sessionId}`);
@@ -711,16 +681,12 @@ export class AgentService implements OnModuleDestroy {
this.assertSessionScope(session, scope); this.assertSessionScope(session, scope);
session.promptCount += 1; 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) // Prepend session-scoped system override if present (renew TTL on each turn)
let effectiveMessage = `${message}${attachmentContext}`; let effectiveMessage = message;
if (this.systemOverride) { if (this.systemOverride) {
const override = await this.systemOverride.get(sessionId, scope); const override = await this.systemOverride.get(sessionId, scope);
if (override) { if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${effectiveMessage}`; effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId, scope); await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`); this.logger.debug(`Applied system override for session ${sessionId}`);
} }

View File

@@ -1,4 +1,3 @@
import type { ChannelAttachmentDto } from '@mosaicstack/types';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
export class ChatRequestDto { export class ChatRequestDto {
@@ -33,7 +32,4 @@ export class ChatSocketMessageDto {
@IsOptional() @IsOptional()
@IsUUID() @IsUUID()
agentId?: string; agentId?: string;
/** Validated channel attachment references; binary content is not embedded. */
attachments?: readonly ChannelAttachmentDto[];
} }

View File

@@ -4,10 +4,6 @@ import { ChatGateway } from './chat.gateway.js';
const CONVERSATION_ID = 'conversation-1'; const CONVERSATION_ID = 'conversation-1';
const CANARY = 'sk_canary12345678'; const CANARY = 'sk_canary12345678';
function clientConversationKey(clientId: string, conversationId: string): string {
return `${clientId}\u0000${conversationId}`;
}
type GatewayInternals = { type GatewayInternals = {
clientSessions: Map<string, unknown>; clientSessions: Map<string, unknown>;
relayEvent(client: unknown, conversationId: string, event: unknown): void; relayEvent(client: unknown, conversationId: string, event: unknown): void;
@@ -44,7 +40,6 @@ describe('ChatGateway redaction boundary', (): void => {
emit: vi.fn(), emit: vi.fn(),
}; };
const session = { const session = {
clientId: client.id,
conversationId: CONVERSATION_ID, conversationId: CONVERSATION_ID,
cleanup: vi.fn(), cleanup: vi.fn(),
assistantText: '', assistantText: '',
@@ -52,7 +47,7 @@ describe('ChatGateway redaction boundary', (): void => {
pendingToolCalls: new Map(), pendingToolCalls: new Map(),
scope: { userId: 'user-1', tenantId: 'tenant-1' }, scope: { userId: 'user-1', tenantId: 'tenant-1' },
}; };
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), session); gateway.clientSessions.set(client.id, session);
gateway.relayEvent(client, CONVERSATION_ID, { gateway.relayEvent(client, CONVERSATION_ID, {
type: 'message_update', type: 'message_update',
@@ -144,51 +139,6 @@ 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 => { it('persists only redacted assistant content with classifications', (): void => {
const { gateway, brain } = buildGateway(); const { gateway, brain } = buildGateway();
const client = { const client = {
@@ -197,8 +147,7 @@ describe('ChatGateway redaction boundary', (): void => {
data: { user: { id: 'user-1' } }, data: { user: { id: 'user-1' } },
emit: vi.fn(), emit: vi.fn(),
}; };
gateway.clientSessions.set(clientConversationKey(client.id, CONVERSATION_ID), { gateway.clientSessions.set(client.id, {
clientId: client.id,
conversationId: CONVERSATION_ID, conversationId: CONVERSATION_ID,
cleanup: vi.fn(), cleanup: vi.fn(),
assistantText: CANARY, assistantText: CANARY,

View File

@@ -17,7 +17,6 @@ import {
parseDiscordInteractionBindings, parseDiscordInteractionBindings,
resolveDiscordInteractionActorId, resolveDiscordInteractionActorId,
resolveDiscordInteractionBinding, resolveDiscordInteractionBinding,
type DiscordAttachment,
type DiscordIngressEnvelope, type DiscordIngressEnvelope,
type DiscordIngressPayload, type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin'; } from '@mosaicstack/discord-plugin';
@@ -31,7 +30,6 @@ import type {
SystemReloadPayload, SystemReloadPayload,
RoutingDecisionInfo, RoutingDecisionInfo,
AbortPayload, AbortPayload,
ChannelAttachmentDto,
} from '@mosaicstack/types'; } from '@mosaicstack/types';
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js'; import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
import { import {
@@ -58,7 +56,6 @@ import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */ /** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession { interface ClientSession {
clientId: string;
conversationId: string; conversationId: string;
cleanup: () => void; cleanup: () => void;
/** Accumulated assistant response text for the current turn. */ /** Accumulated assistant response text for the current turn. */
@@ -79,68 +76,6 @@ interface ClientSession {
*/ */
const modelOverrides = new Map<string, string>(); const modelOverrides = new Map<string, string>();
const MAX_REDACTION_BUFFER_LENGTH = 8_192; 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<DiscordAttachment>;
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 { function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
@@ -153,49 +88,23 @@ function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelo
return false; return false;
} }
const payload = envelope.payload as Record<string, unknown>; const payload = envelope.payload as Record<string, unknown>;
return ( return [
[ payload['correlationId'],
payload['correlationId'], payload['messageId'],
payload['messageId'], payload['guildId'],
payload['guildId'], payload['channelId'],
payload['channelId'], payload['userId'],
payload['userId'], payload['conversationId'],
payload['conversationId'], payload['content'],
payload['content'], ].every((field: unknown): boolean => typeof field === 'string');
].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<ChannelAttachmentDto>;
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 { function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
if (typeof value !== 'object' || value === null) return false; if (typeof value !== 'object' || value === null) return false;
const payload = value as { const payload = value as { content?: unknown; conversationId?: unknown };
content?: unknown;
conversationId?: unknown;
attachments?: unknown;
};
return ( return (
typeof payload.content === 'string' && 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))
); );
} }
@@ -265,24 +174,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
handleDisconnect(client: Socket): void { handleDisconnect(client: Socket): void {
this.logger.log(`Client disconnected: ${client.id}`); this.logger.log(`Client disconnected: ${client.id}`);
for (const [key, session] of this.clientSessions) { const session = this.clientSessions.get(client.id);
if (session.clientId !== client.id) continue; if (session) {
session.cleanup(); session.cleanup();
this.agentService.removeChannel( this.agentService.removeChannel(
session.conversationId, session.conversationId,
`websocket:${client.id}`, `websocket:${client.id}`,
session.scope, session.scope,
); );
this.clientSessions.delete(key); this.clientSessions.delete(client.id);
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);
private clientConversationKey(client: Pick<Socket, 'id'>, conversationId: string): string { this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
return `${client.id}\u0000${conversationId}`; this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
} }
private getClientScope(client: Socket): ActorTenantScope | null { private getClientScope(client: Socket): ActorTenantScope | null {
@@ -313,25 +218,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
discordIngress = this.resolveDiscordIngress(client, rawData); discordIngress = this.resolveDiscordIngress(client, rawData);
if (!discordIngress) return; if (!discordIngress) return;
data = { data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
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 { } else {
if (!isChatSocketMessage(rawData)) { if (!isChatSocketMessage(rawData)) {
this.logger.warn(`Rejected malformed chat message from ${client.id}`); this.logger.warn(`Rejected malformed chat message from ${client.id}`);
@@ -340,7 +227,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
data = rawData; data = rawData;
} }
const conversationId = data.conversationId ?? uuid(); const conversationId = data.conversationId ?? uuid();
const clientConversationKey = this.clientConversationKey(client, conversationId);
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) { if (discordIngress && !discordServiceUserId) {
this.logger.warn( this.logger.warn(
@@ -395,7 +281,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
this.logger.log( this.logger.log(
`Using /model override "${modelOverride}" for conversation=${conversationId}`, `Using /model override "${modelOverride}" for conversation=${conversationId}`,
); );
} else if (!resolvedProvider && !resolvedModelId && !discordIngress) { } else if (!resolvedProvider && !resolvedModelId) {
// No explicit provider/model from client — use routing engine (M4-012) // No explicit provider/model from client — use routing engine (M4-012)
try { try {
const routingDecision = await this.routingEngine.resolve(data.content, userId); const routingDecision = await this.routingEngine.resolve(data.content, userId);
@@ -418,24 +304,12 @@ 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) // M5-004: Use existingSessionId as sessionId when available (session reuse)
const sessionIdToCreate = existingSessionId ?? conversationId; const sessionIdToCreate = existingSessionId ?? conversationId;
agentSession = await this.agentService.createSession(sessionIdToCreate, { agentSession = await this.agentService.createSession(sessionIdToCreate, {
provider: resolvedProvider, provider: resolvedProvider,
modelId: resolvedModelId, modelId: resolvedModelId,
agentConfigId: resolvedAgentConfigId, agentConfigId: data.agentId,
userId, userId,
tenantId: scope.tenantId, tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
@@ -486,17 +360,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
discordUserId: discordIngress?.userId, 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, classifications: redactSensitiveContent(data.content).classifications,
}, },
}, },
@@ -511,7 +374,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Always clean up previous listener to prevent leak // Always clean up previous listener to prevent leak
const existing = this.clientSessions.get(clientConversationKey); const existing = this.clientSessions.get(client.id);
if (existing) { if (existing) {
existing.cleanup(); existing.cleanup();
} }
@@ -526,11 +389,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
); );
// Preserve routing decision from the existing client session if we didn't get a new one // Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(clientConversationKey); const prevClientSession = this.clientSessions.get(client.id);
const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision; const routingDecisionToStore = sessionRoutingDecision ?? prevClientSession?.lastRoutingDecision;
this.clientSessions.set(clientConversationKey, { this.clientSessions.set(client.id, {
clientId: client.id,
conversationId, conversationId,
cleanup, cleanup,
assistantText: '', assistantText: '',
@@ -576,7 +438,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
// Dispatch to agent // Dispatch to agent
try { try {
await this.agentService.prompt(conversationId, data.content, scope, data.attachments); await this.agentService.prompt(conversationId, data.content, scope);
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`, `Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -783,9 +645,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}; };
// Emit to all clients currently subscribed to this conversation // Emit to all clients currently subscribed to this conversation
for (const session of this.clientSessions.values()) { for (const [clientId, session] of this.clientSessions) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(session.clientId); const socket = this.server.sockets.sockets.get(clientId);
if (socket?.connected) { if (socket?.connected) {
socket.emit('session:info', payload); socket.emit('session:info', payload);
} }
@@ -815,10 +677,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
!this.durableSessions !this.durableSessions
) )
return; return;
const binding = this.discordBindingFor(ingress, 'approve'); const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'approve',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
const agentName = binding?.instanceId; const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
if (!actorId || !agentName) { if (!actorId || !agentName || binding.instanceId !== agentName) {
this.logger.warn( this.logger.warn(
`Rejected Discord approval without a matching runtime agent from ${client.id}`, `Rejected Discord approval without a matching runtime agent from ${client.id}`,
); );
@@ -906,7 +774,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
return; return;
const binding = this.discordBindingFor(ingress, 'stop'); const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
ingress.guildId,
ingress.channelId,
ingress.userId,
'stop',
);
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
if (!actorId) return; if (!actorId) return;
@@ -980,18 +854,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return null; return null;
} }
try { try {
const binding = this.discordBindingFor(payload, operation); const binding = resolveDiscordInteractionBinding(
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
payload.guildId,
payload.channelId,
payload.userId,
operation,
);
if (!binding) { if (!binding) {
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
return null; 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 { } catch {
this.logger.warn( this.logger.warn(
`Rejected Discord ingress without valid binding configuration from ${client.id}`, `Rejected Discord ingress without valid binding configuration from ${client.id}`,
@@ -1007,19 +880,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return payload; 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[] { private readDiscordAllowlist(name: string): string[] {
return (process.env[name] ?? '') return (process.env[name] ?? '')
.split(',') .split(',')
@@ -1098,15 +958,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const messages = await this.brain.conversations.findMessages(conversationId, userId); const messages = await this.brain.conversations.findMessages(conversationId, userId);
if (messages.length === 0) return []; if (messages.length === 0) return [];
return messages.map((msg) => { return messages.map((msg) => ({
const attachments = this.persistedChannelAttachments(msg.metadata); role: msg.role as 'user' | 'assistant' | 'system',
return { content: msg.content,
role: msg.role as 'user' | 'assistant' | 'system', createdAt: msg.createdAt,
content: msg.content, }));
createdAt: msg.createdAt,
...(attachments ? { attachments } : {}),
};
});
} catch (err) { } catch (err) {
this.logger.error( this.logger.error(
`Failed to load conversation history for conversation=${conversationId}`, `Failed to load conversation history for conversation=${conversationId}`,
@@ -1116,14 +972,6 @@ 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( private appendAndFlushRedactedEgress(
client: Socket, client: Socket,
conversationId: string, conversationId: string,
@@ -1131,19 +979,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>, buffers: Map<string, string>,
delta: string, delta: string,
): void { ): void {
const sessionKey = this.clientConversationKey(client, conversationId); const key = this.egressKey(client, eventName);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) return; if (this.overflowedEgress.has(key)) return;
const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`; const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
buffers.delete(sessionKey); buffers.delete(client.id);
this.overflowedEgress.add(key); this.overflowedEgress.add(key);
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
return; return;
} }
buffers.set(sessionKey, buffered); buffers.set(client.id, buffered);
this.flushRedactedEgress(client, conversationId, eventName, buffers, false); this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
} }
@@ -1159,22 +1006,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
buffers: Map<string, string>, buffers: Map<string, string>,
final: boolean, final: boolean,
): void { ): void {
const sessionKey = this.clientConversationKey(client, conversationId); const key = this.egressKey(client, eventName);
const key = this.egressKey(client, conversationId, eventName);
if (this.overflowedEgress.has(key)) { if (this.overflowedEgress.has(key)) {
if (final) this.overflowedEgress.delete(key); if (final) this.overflowedEgress.delete(key);
return; return;
} }
const buffered = buffers.get(sessionKey) ?? ''; const buffered = buffers.get(client.id) ?? '';
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
const released = buffered.slice(0, releaseLength); const released = buffered.slice(0, releaseLength);
const pending = buffered.slice(releaseLength); const pending = buffered.slice(releaseLength);
if (pending) { if (pending) {
buffers.set(sessionKey, pending); buffers.set(client.id, pending);
} else { } else {
buffers.delete(sessionKey); buffers.delete(client.id);
} }
if (released) { if (released) {
@@ -1243,12 +1089,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return retainedFrom; return retainedFrom;
} }
private egressKey( private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
client: Socket, return `${client.id}:${eventName}`;
conversationId: string,
eventName: 'agent:text' | 'agent:thinking',
): string {
return `${this.clientConversationKey(client, conversationId)}:${eventName}`;
} }
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void { private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
@@ -1259,27 +1101,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
return; return;
} }
const sessionKey = this.clientConversationKey(client, conversationId);
switch (event.type) { switch (event.type) {
case 'agent_start': { case 'agent_start': {
// Reset accumulation buffers for the new turn // Reset accumulation buffers for the new turn
const cs = this.clientSessions.get(sessionKey); const cs = this.clientSessions.get(client.id);
if (cs) { if (cs) {
cs.assistantText = ''; cs.assistantText = '';
cs.toolCalls = []; cs.toolCalls = [];
cs.pendingToolCalls.clear(); cs.pendingToolCalls.clear();
} }
this.textEgressBuffers.set(sessionKey, ''); this.textEgressBuffers.set(client.id, '');
this.thinkingEgressBuffers.set(sessionKey, ''); this.thinkingEgressBuffers.set(client.id, '');
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text')); this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking')); this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
client.emit('agent:start', { conversationId }); client.emit('agent:start', { conversationId });
break; break;
} }
case 'agent_end': { case 'agent_end': {
// Gather usage stats from the Pi session // Gather usage stats from the Pi session
const activeClientSession = this.clientSessions.get(sessionKey); const activeClientSession = this.clientSessions.get(client.id);
const agentSession = activeClientSession const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope) ? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined; : undefined;
@@ -1332,7 +1173,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
} }
// Persist the assistant message with metadata // Persist the assistant message with metadata
const cs = this.clientSessions.get(sessionKey); const cs = this.clientSessions.get(client.id);
const userId = (client.data.user as { id: string } | undefined)?.id; const userId = (client.data.user as { id: string } | undefined)?.id;
if (cs && userId && cs.assistantText.trim().length > 0) { if (cs && userId && cs.assistantText.trim().length > 0) {
const metadata: Record<string, unknown> = { const metadata: Record<string, unknown> = {
@@ -1384,7 +1225,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const assistantEvent = event.assistantMessageEvent; const assistantEvent = event.assistantMessageEvent;
if (assistantEvent.type === 'text_delta') { if (assistantEvent.type === 'text_delta') {
// Keep raw stream material in memory only; persist and emit only redacted text. // Keep raw stream material in memory only; persist and emit only redacted text.
const cs = this.clientSessions.get(sessionKey); const cs = this.clientSessions.get(client.id);
if (cs) { if (cs) {
cs.assistantText += assistantEvent.delta; cs.assistantText += assistantEvent.delta;
} }
@@ -1409,7 +1250,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_start': { case 'tool_execution_start': {
// Track pending tool call for later recording // Track pending tool call for later recording
const cs = this.clientSessions.get(sessionKey); const cs = this.clientSessions.get(client.id);
if (cs) { if (cs) {
cs.pendingToolCalls.set(event.toolCallId, { cs.pendingToolCalls.set(event.toolCallId, {
toolName: event.toolName, toolName: event.toolName,
@@ -1426,7 +1267,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'tool_execution_end': { case 'tool_execution_end': {
// Finalise tool call record // Finalise tool call record
const cs = this.clientSessions.get(sessionKey); const cs = this.clientSessions.get(client.id);
if (cs) { if (cs) {
const pending = cs.pendingToolCalls.get(event.toolCallId); const pending = cs.pendingToolCalls.get(event.toolCallId);
cs.toolCalls.push({ cs.toolCalls.push({

View File

@@ -24,7 +24,6 @@ const ENV_KEYS = [
'DISCORD_ALLOWED_CHANNEL_IDS', 'DISCORD_ALLOWED_CHANNEL_IDS',
'DISCORD_ALLOWED_USER_IDS', 'DISCORD_ALLOWED_USER_IDS',
'MOSAIC_AGENT_NAME', 'MOSAIC_AGENT_NAME',
'MOSAIC_AGENT_CONFIG_ID',
] as const; ] as const;
const savedEnv = new Map<string, string | undefined>(); const savedEnv = new Map<string, string | undefined>();
@@ -34,14 +33,12 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service'; process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord'; process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
process.env['MOSAIC_AGENT_NAME'] = 'Nova'; 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_GUILD_IDS'] = 'guild-001';
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001'; process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001'; process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([ process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { pairedUsers: {
@@ -144,7 +141,7 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
userId: 'user-001', userId: 'user-001',
conversationId: 'Nova:discord:channel-001', conversationId: 'discord-channel-001',
content: 'hello Tess', content: 'hello Tess',
...overrides, ...overrides,
}; };
@@ -156,7 +153,6 @@ describe('Discord ingress security', () => {
JSON.stringify([ JSON.stringify([
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': 'admin' }, pairedUsers: { 'user-001': 'admin' },
@@ -174,7 +170,6 @@ describe('Discord ingress security', () => {
[ [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
@@ -312,46 +307,41 @@ describe('Discord ingress security', () => {
); );
}); });
it('rejects approval when the durable session targets a different logical agent', async () => { it.each([
configureDiscordEnv(); [
const { gateway, client, durable } = discordGateway('admin'); 'binding',
durable.getSnapshot.mockResolvedValueOnce({ () => {
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' }, process.env['MOSAIC_AGENT_NAME'] = 'Other';
}); },
],
[
'durable session',
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
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);
await gateway.handleDiscordApproval( await gateway.handleDiscordApproval(
client as never, client as never,
ingressEnvelope('/approve', 'mismatched-agent-approve'), ingressEnvelope('/approve', 'mismatched-agent-approve'),
); );
expect(client.emit).toHaveBeenCalledWith('discord:approval', { expect(client.emit).toHaveBeenCalledWith('discord:approval', {
correlationId: 'correlation-001', correlationId: 'correlation-001',
success: false, success: false,
approvalId: undefined, approvalId: undefined,
expiresAt: 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 () => { it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
configureDiscordEnv(); configureDiscordEnv();
@@ -408,267 +398,6 @@ 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<Array<{ attachments?: readonly (typeof attachment)[] }>>;
};
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<string, unknown> = {
...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', () => { it('accepts a thread message through its allowed bound parent channel', () => {
const emitted = vi.fn(); const emitted = vi.fn();
const plugin = new DiscordPlugin({ const plugin = new DiscordPlugin({
@@ -681,7 +410,6 @@ describe('Discord ingress security', () => {
interactionBindings: [ interactionBindings: [
{ {
instanceId: 'Nova', instanceId: 'Nova',
agentConfigId: 'agent-config-nova',
guildId: 'guild-001', guildId: 'guild-001',
channelId: 'channel-001', channelId: 'channel-001',
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } }, pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },

View File

@@ -61,16 +61,6 @@ function requiredDiscordAllowlist(name: string): string[] {
return value; 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[] { function createPluginRegistry(): IChannelPlugin[] {
const plugins: IChannelPlugin[] = []; const plugins: IChannelPlugin[] = [];
const discordToken = process.env['DISCORD_BOT_TOKEN']; const discordToken = process.env['DISCORD_BOT_TOKEN'];
@@ -92,10 +82,6 @@ function createPluginRegistry(): IChannelPlugin[] {
guildId: discordGuildId, guildId: discordGuildId,
gatewayUrl: discordGatewayUrl, gatewayUrl: discordGatewayUrl,
serviceToken: discordServiceToken, serviceToken: discordServiceToken,
messageRateLimitPerMinute: optionalPositiveInteger(
'DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE',
),
threadRateLimitPerMinute: optionalPositiveInteger('DISCORD_THREAD_RATE_LIMIT_PER_MINUTE'),
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),

View File

@@ -175,69 +175,6 @@ 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 ## Architecture
### High-Level System Diagram ### High-Level System Diagram
@@ -592,8 +529,7 @@ Discord remote control channel. Architecture inspired by OpenClaw (https://githu
- Single-guild binding only (v0.1.0) — prevents data leaks between servers - Single-guild binding only (v0.1.0) — prevents data leaks between servers
- Receives Discord messages, dispatches through gateway routing - Receives Discord messages, dispatches through gateway routing
- Streams agent responses back to Discord (chunked for 2000-char limit) - Streams agent responses back to Discord (chunked for 2000-char limit)
- Routes authorized untagged messages in-channel; mentions create threads (or reuse the same message's attached thread) for multi-turn topics - Supports mention-based activation, thread management for multi-turn
- Uses stable logical-agent/channel conversation addresses independent of the active harness/provider
- Bot pairing and permission management (Discord user → Mosaic user mapping) - Bot pairing and permission management (Discord user → Mosaic user mapping)
- DM support for private conversations - DM support for private conversations
@@ -766,12 +702,10 @@ Telegram remote control channel.
### FR-9: Remote Control — Discord ### FR-9: Remote Control — Discord
- Discord bot that connects to the gateway through a transport-neutral channel adapter contract - Discord bot that connects to the gateway
- Authorized messages in configured agent-bound channels work without a mention and respond in-channel - Mention-based activation in channels
- 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 - DM support for private conversations
- Thread creation for multi-turn conversations
- Chunked message delivery (Discord 2000-char limit) - Chunked message delivery (Discord 2000-char limit)
- Bot configuration via web dashboard - Bot configuration via web dashboard
- Permission management (which Discord users/roles can interact) - Permission management (which Discord users/roles can interact)
@@ -955,13 +889,10 @@ Telegram remote control channel.
### AC-3: Discord Remote Control ### AC-3: Discord Remote Control
- [ ] Discord bot connects through the harness-neutral channel contract - [ ] Discord bot connects and responds to mentions
- [ ] Authorized untagged channel messages route through the gateway and respond in-channel - [ ] Messages route through gateway to agent pool
- [ ] 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) - [ ] Responses stream back to Discord (chunked)
- [ ] Unauthorized guilds, channels, users, pairings, and roles create no thread and dispatch no message - [ ] Thread creation for multi-turn conversations
### AC-4: Gateway Orchestration ### AC-4: Gateway Orchestration
@@ -1160,7 +1091,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. 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**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. 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.
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. 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.

View File

@@ -1,13 +1,5 @@
# Documentation Sitemap # 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 ## Native Kanban and canonical task SOT
- [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0P3 requirements and acceptance criteria. - [Canonical requirements](requirements/native-kanban-sot.md) — ratified P0P3 requirements and acceptance criteria.
@@ -49,3 +41,10 @@
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md) - [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
- [Verification matrix](tess/VERIFICATION-MATRIX.md) - [Verification matrix](tess/VERIFICATION-MATRIX.md)
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md) - [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md)
## Runtime-neutral Mos portability
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)

View File

@@ -0,0 +1,151 @@
# ADR: Optional AI egress gateways for runtime-neutral Mos
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
**Date:** 2026-07-14
**Issues:** #754, #755
**Decision owner:** Mosaic Gateway / provider-adapter architecture
## Context
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
## Decision
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
Mosaic Gateway remains authoritative for:
- authenticated actor and tenant identity;
- logical agent identity and connector binding;
- authorization, approval, and policy;
- lease epoch and stale-holder fencing;
- audit correlation and redaction;
- canonical handoff/checkpoint state;
- idempotency and side-effect receipts.
An egress gateway MUST NOT:
- receive channel ingress directly;
- authorize tools or connector ownership;
- define Mosaic tenant or agent identity;
- persist raw Mosaic handoffs or channel credentials;
- bypass adapter capability negotiation;
- silently fail over when policy, lease, or provider health is uncertain.
Allowed topology:
```text
Discord / Matrix / CLI / web
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
IProviderAdapter / AgentRuntimeProvider
optional egress gateway
upstream provider or subscription-backed OAuth session
```
## Candidate assessment
### Purpose-built `raine/claude-code-proxy`
**Disposition:** Approved only for the verified emergency localhost bridge.
Strengths:
- explicit Codex device OAuth flow;
- small operational surface;
- Anthropic Messages translation suitable for Claude Code;
- model and reasoning-effort enforcement;
- straightforward loopback systemd supervision and rollback.
Constraints:
- not a Mosaic multi-tenant control plane;
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
### LiteLLM
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
Required prototype gates:
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
- document token location, encryption, revocation, refresh, scope, and incident response;
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
- fail closed instead of selecting an unhealthy provider merely to return a result;
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
- keep channel ingress and connector credentials outside LiteLLM.
Source references:
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
### Bifrost
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
Required prototype gates:
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
- prove failover does not violate connector lease, approval, or exactly-once semantics;
- ensure request/response logs are redacted before persistence;
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
Source references:
- [Bifrost overview](https://docs.getbifrost.ai/overview)
- [Bifrost repository](https://github.com/maximhq/bifrost)
### `teremterem/claude-code-gpt-5-codex`
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
Source references:
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
## Security consequences
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
## Acceptance before production use
1. Threat model and provider-terms review approved.
2. Credential lifecycle and revocation drill documented and exercised.
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
5. Failure injection proves no duplicate side effects across retries or provider failover.
6. Rollback to the prior provider path is exercised.
7. Independent code and security reviews approve the exact deployed revision.
## Follow-up
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.

View File

@@ -1,9 +1,9 @@
# Channel Protocol Architecture # Channel Protocol Architecture
**Status:** Official adapter baseline implemented by #756; extended registry/multiplexing remains iterative **Status:** Draft
**Authors:** Mosaic Core Team **Authors:** Mosaic Core Team
**Last Updated:** 2026-07-14 **Last Updated:** 2026-03-22
**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) **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)
--- ---
@@ -11,80 +11,93 @@
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 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 implemented baseline is exported from `@mosaicstack/types` and consists of four contract groups: The protocol consists of two main contracts:
1. `OfficialChannelAdapter` — transport lifecycle and connection health. 1. `IChannelAdapter` — the interface each channel driver must implement.
2. `ChannelMessageDto` / `ChannelAttachmentDto` — canonical transport data. 2. `ChannelMessage` — the canonical message format that flows through the system.
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. 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. All channel-specific translation logic lives inside the adapter implementation. The rest of Mosaic works exclusively with `ChannelMessage` objects.
--- ---
## M7-001: OfficialChannelAdapter Interface ## M7-001: IChannelAdapter Interface
```typescript ```typescript
interface OfficialChannelAdapter { interface IChannelAdapter {
/** Stable, lowercase adapter identifier such as "discord" or "matrix". */ /**
* Stable, lowercase identifier for this channel (e.g. "matrix", "discord").
* Used as a namespace key in registry lookups and log metadata.
*/
readonly name: string; readonly name: string;
/** Establish both native-channel and gateway connections. */
start(): Promise<void>; /**
/** Gracefully close connections and release resources. */ * Establish a connection to the external channel backend.
stop(): Promise<void>; * Called once at application startup. Must be idempotent (safe to call
/** Best-effort health; ordinary disconnection is a result, not an exception. */ * when already connected).
health(): Promise<{ */
status: 'connected' | 'degraded' | 'disconnected'; connect(): Promise<void>;
detail?: string;
}>; /**
* Gracefully disconnect from the channel backend.
* Must flush in-flight sends and release resources before resolving.
*/
disconnect(): Promise<void>;
/**
* 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>): 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<void>;
/**
* 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<string | null>;
} }
``` ```
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 ### Adapter Registration
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. 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).
``` ```
ChannelRegistry ChannelRegistry
└── register(adapter: OfficialChannelAdapter): void └── register(adapter: IChannelAdapter): void
└── getAdapter(name: string): OfficialChannelAdapter | null └── getAdapter(name: string): IChannelAdapter | null
└── listAdapters(): OfficialChannelAdapter[] └── listAdapters(): IChannelAdapter[]
└── healthAll(): Promise<Record<string, AdapterHealth>> └── healthAll(): Promise<Record<string, AdapterHealth>>
``` ```
--- ---
## M7-002: ChannelMessageDto Protocol ## M7-002: ChannelMessage Protocol
### Canonical Message Format ### Canonical Message Format
```typescript ```typescript
interface ChannelMessageDto { interface ChannelMessage {
/** /**
* Globally unique message ID. * Globally unique message ID.
* Format: UUID v4. Generated by the adapter when receiving, or by Mosaic * Format: UUID v4. Generated by the adapter when receiving, or by Mosaic
@@ -97,7 +110,6 @@ interface ChannelMessageDto {
* The adapter populates this from the inbound message. * The adapter populates this from the inbound message.
* For outbound messages, the caller supplies the target channel. * For outbound messages, the caller supplies the target channel.
*/ */
channelName: string;
channelId: string; channelId: string;
/** /**
@@ -107,7 +119,7 @@ interface ChannelMessageDto {
senderId: string; senderId: string;
/** Sender classification. */ /** Sender classification. */
senderKind: 'user' | 'agent' | 'system'; senderType: 'user' | 'agent' | 'system';
/** /**
* Textual content of the message. * Textual content of the message.
@@ -124,7 +136,7 @@ interface ChannelMessageDto {
* - "image" — binary image; content is empty, see attachments * - "image" — binary image; content is empty, see attachments
* - "file" — binary file; content is empty, see attachments * - "file" — binary file; content is empty, see attachments
*/ */
contentKind: 'text' | 'markdown' | 'code' | 'image' | 'file'; contentType: 'text' | 'markdown' | 'code' | 'image' | 'file';
/** /**
* Arbitrary key-value metadata for channel-specific extension fields. * Arbitrary key-value metadata for channel-specific extension fields.
@@ -132,7 +144,7 @@ interface ChannelMessageDto {
* Adapters should store channel-native IDs here so round-trip correlation * Adapters should store channel-native IDs here so round-trip correlation
* is possible without altering the canonical fields. * is possible without altering the canonical fields.
*/ */
metadata: Readonly<Record<string, ChannelMetadataValue>>; metadata: Record<string, unknown>;
/** /**
* Optional thread or reply-chain identifier. * Optional thread or reply-chain identifier.
@@ -151,21 +163,18 @@ interface ChannelMessageDto {
* Binary or URI-referenced attachments. * Binary or URI-referenced attachments.
* Each attachment carries its MIME type and a URL or base64 payload. * Each attachment carries its MIME type and a URL or base64 payload.
*/ */
attachments?: readonly ChannelAttachmentDto[]; attachments?: ChannelAttachment[];
/** ISO-8601 wall-clock timestamp when the message was sent/received. */ /** Wall-clock timestamp when the message was sent/received. */
timestamp: string; timestamp: Date;
} }
interface ChannelAttachmentDto { interface ChannelAttachment {
/** Channel-native attachment identifier. */ /** Filename or identifier. */
id: string;
/** Filename or display name. */
name: string; name: string;
/** MIME type when supplied by the channel. */ /** MIME type (e.g. "image/png", "application/pdf"). */
mimeType: string | null; mimeType: string;
/** /**
* URL pointing to the attachment, OR a `data:` URI with base64 payload. * URL pointing to the attachment, OR a `data:` URI with base64 payload.
@@ -183,23 +192,23 @@ interface ChannelAttachmentDto {
## Channel Translation Reference ## Channel Translation Reference
The following sections document how each supported channel maps its native message format to and from `ChannelMessageDto`. The following sections document how each supported channel maps its native message format to and from `ChannelMessage`.
### Matrix ### Matrix
| ChannelMessageDto field | Matrix equivalent | | ChannelMessage field | Matrix equivalent |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) | | `id` | Generated UUID; `metadata.channelMessageId` = Matrix event ID (`$...`) |
| `channelId` | Matrix room ID (`!roomid:homeserver`) | | `channelId` | Matrix room ID (`!roomid:homeserver`) |
| `senderId` | Matrix user ID (`@user:homeserver`) | | `senderId` | Matrix user ID (`@user:homeserver`) |
| `senderKind` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound | | `senderType` | Always `"user"` for inbound; `"agent"` or `"system"` for outbound |
| `content` | `event.content.body` | | `content` | `event.content.body` |
| `contentKind` | `"markdown"` if `msgtype = m.text` and body contains markdown; `"text"` otherwise; `"image"` for `m.image`; `"file"` for `m.file` | | `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` | | `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']` | | `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 | | `attachments` | Populated from `url` in `m.image` / `m.file` events |
| `timestamp` | `new Date(event.origin_server_ts)` | | `timestamp` | `new Date(event.origin_server_ts)` |
| `metadata` | `{ channelMessageId, roomId, eventType, unsigned }` | | `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. **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.
@@ -207,34 +216,21 @@ The following sections document how each supported channel maps its native messa
### Discord ### Discord
| ChannelMessageDto field | Discord equivalent | | ChannelMessage field | Discord equivalent |
| ----------------------- | ----------------------------------------------------------------------- | | -------------------- | ----------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake | | `id` | Generated UUID; `metadata.channelMessageId` = Discord message snowflake |
| `channelId` | Discord channel ID (snowflake string) | | `channelId` | Discord channel ID (snowflake string) |
| `senderId` | Discord user ID (snowflake) | | `senderId` | Discord user ID (snowflake) |
| `senderKind` | `"user"` for human members; `"agent"` for bot messages | | `senderType` | `"user"` for human members; `"agent"` for bot messages |
| `content` | `message.content` | | `content` | `message.content` |
| `contentKind` | `"markdown"` (Discord uses a markdown-like syntax natively) | | `contentType` | `"markdown"` (Discord uses a markdown-like syntax natively) |
| `threadId` | `message.thread.id` when the message is inside a thread channel | | `threadId` | `message.thread.id` when the message is inside a thread channel |
| `replyToId` | Mosaic ID looked up from `message.referenced_message.id` | | `replyToId` | Mosaic ID looked up from `message.referenced_message.id` |
| `attachments` | `message.attachments` mapped to `ChannelAttachmentDto` | | `attachments` | `message.attachments` mapped to `ChannelAttachment` |
| `timestamp` | `new Date(message.timestamp)` | | `timestamp` | `new Date(message.timestamp)` |
| `metadata` | `{ channelMessageId, guildId, channelType, mentions, embeds }` | | `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 `contentKind = "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 `contentType = "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 <approval>` | 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 ### Discord service ingress security
@@ -244,21 +240,21 @@ The Discord adapter is an authenticated gateway service, not an anonymous Socket
### Telegram ### Telegram
| ChannelMessageDto field | Telegram equivalent | | ChannelMessage field | Telegram equivalent |
| ----------------------- | ------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------------- |
| `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) | | `id` | Generated UUID; `metadata.channelMessageId` = Telegram `message_id` (integer) |
| `channelId` | Telegram `chat_id` (integer as string) | | `channelId` | Telegram `chat_id` (integer as string) |
| `senderId` | Telegram `from.id` (integer as string) | | `senderId` | Telegram `from.id` (integer as string) |
| `senderKind` | `"user"` for human senders; `"agent"` for bot-originated messages | | `senderType` | `"user"` for human senders; `"agent"` for bot-originated messages |
| `content` | `message.text` or `message.caption` | | `content` | `message.text` or `message.caption` |
| `contentKind` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` | | `contentType` | `"text"` for plain; `"markdown"` if `parse_mode = MarkdownV2`; `"image"` for `photo`; `"file"` for `document` |
| `threadId` | `message.message_thread_id` (for supergroup topics) | | `threadId` | `message.message_thread_id` (for supergroup topics) |
| `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` | | `replyToId` | Mosaic ID looked up from `message.reply_to_message.message_id` |
| `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachmentDto` | | `attachments` | `photo`, `document`, `video` fields mapped to `ChannelAttachment` |
| `timestamp` | `new Date(message.date * 1000)` | | `timestamp` | `new Date(message.date * 1000)` |
| `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` | | `metadata` | `{ channelMessageId, chatType, fromUsername, forwardFrom }` |
**Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentKind = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`. **Outbound:** Adapter calls Telegram Bot API `sendMessage` with `parse_mode = MarkdownV2` for markdown content. For `contentType = "image"` or `"file"` it uses `sendPhoto` / `sendDocument`.
--- ---
@@ -266,19 +262,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. 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.
| ChannelMessageDto field | TUI equivalent | | ChannelMessage field | TUI equivalent |
| ----------------------- | ------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------------ |
| `id` | Generated UUID (TUI has no native message IDs) | | `id` | Generated UUID (TUI has no native message IDs) |
| `channelId` | `"tui:<conversationId>"` — the active conversation ID | | `channelId` | `"tui:<conversationId>"` — the active conversation ID |
| `senderId` | Authenticated Mosaic `userId` | | `senderId` | Authenticated Mosaic `userId` |
| `senderKind` | `"user"` for human input; `"agent"` for agent replies | | `senderType` | `"user"` for human input; `"agent"` for agent replies |
| `content` | Raw text from stdin / agent output | | `content` | Raw text from stdin / agent output |
| `contentKind` | `"text"` for input; `"markdown"` for agent responses | | `contentType` | `"text"` for input; `"markdown"` for agent responses |
| `threadId` | Not used (TUI sessions are linear) | | `threadId` | Not used (TUI sessions are linear) |
| `replyToId` | Not used | | `replyToId` | Not used |
| `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs | | `attachments` | File paths dragged/pasted into the TUI; resolved to `file://` URLs |
| `timestamp` | `new Date()` at the moment of send | | `timestamp` | `new Date()` at the moment of send |
| `metadata` | `{ conversationId, sessionId, ttyWidth, colorSupport }` | | `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`. **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`.
@@ -288,19 +284,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`). The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel protocol over the existing Socket.IO gateway (`apps/gateway`).
| ChannelMessageDto field | WebUI equivalent | | ChannelMessage field | WebUI equivalent |
| ----------------------- | ------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------ |
| `id` | Generated UUID; echoed back in the WebSocket event | | `id` | Generated UUID; echoed back in the WebSocket event |
| `channelId` | `"webui:<conversationId>"` | | `channelId` | `"webui:<conversationId>"` |
| `senderId` | Authenticated Mosaic `userId` | | `senderId` | Authenticated Mosaic `userId` |
| `senderKind` | `"user"` for browser input; `"agent"` for agent responses | | `senderType` | `"user"` for browser input; `"agent"` for agent responses |
| `content` | Message text from the input field | | `content` | Message text from the input field |
| `contentKind` | `"text"` or `"markdown"` | | `contentType` | `"text"` or `"markdown"` |
| `threadId` | Not used (conversation model handles threading) | | `threadId` | Not used (conversation model handles threading) |
| `replyToId` | Message ID the user replied to (UI reply affordance) | | `replyToId` | Message ID the user replied to (UI reply affordance) |
| `attachments` | Files uploaded via the file picker; stored to object storage | | `attachments` | Files uploaded via the file picker; stored to object storage |
| `timestamp` | `new Date()` at send, or server timestamp from event | | `timestamp` | `new Date()` at send, or server timestamp from event |
| `metadata` | `{ conversationId, sessionId, clientTimezone, userAgent }` | | `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. **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.
@@ -308,7 +304,7 @@ The WebUI adapter connects the Next.js frontend (`apps/web`) to the channel prot
## Identity Mapping ## Identity Mapping
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. `mapIdentity(channelUserId)` resolves a channel-native user identifier to a Mosaic `userId`. This is required to attribute inbound messages to authenticated Mosaic accounts.
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). 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).
@@ -327,8 +323,8 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Error Handling Conventions ## Error Handling Conventions
- `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. - `connect()` must throw a structured error (subclass of `ChannelConnectError`) if the initial connection cannot be established within a reasonable timeout (default: 10 s).
- `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. - `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.
- `health()` must never throw — it returns `{ status: 'disconnected' }` on error. - `health()` must never throw — it returns `{ status: 'disconnected' }` on error.
- Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability. - Adapters must emit structured logs with `{ channel: adapter.name, event, ... }` metadata for observability.
@@ -336,7 +332,7 @@ Identity linking flows (OAuth dance, deep-link verification token, etc.) are out
## Versioning ## Versioning
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. 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.
Current version: **1.0.0** Current version: **1.0.0**
@@ -477,7 +473,7 @@ A single Mosaic conversation can be accessed simultaneously from multiple surfac
### Real-Time Sync Flow ### Real-Time Sync Flow
1. A message arrives on any surface (TUI keystroke, browser send, Matrix event). 1. A message arrives on any surface (TUI keystroke, browser send, Matrix event).
2. The surface's adapter normalizes the message to `ChannelMessageDto` and delivers it to `ConversationService`. 2. The surface's adapter normalizes the message to `ChannelMessage` 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`. 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: 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. - TUI adapter: writes rendered output to the connected terminal session.
@@ -565,7 +561,7 @@ Matrix sessions for linked users are persistent and long-lived. Unlike TUI sessi
- Their `channel_identities` row exists (link not revoked). - Their `channel_identities` row exists (link not revoked).
- They remain members of the relevant Matrix rooms. - They remain members of the relevant Matrix rooms.
Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) 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). Revoking a Matrix link (`DELETE /auth/channel-link/matrix/<matrixUserId>`) 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).
--- ---
@@ -737,7 +733,7 @@ room_retention_policies
created_at TIMESTAMP 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 `ChannelMessageDto` 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 `ChannelMessage` record unless the Mosaic retention policy also covers it.
Default retention values: Default retention values:

View File

@@ -293,64 +293,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
### Plugins ### Plugins
| Variable | Description | | Variable | Description |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) | | `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_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_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_GUILD_ID` | Discord guild/server ID |
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) | | `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_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_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_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 | | `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
| `DISCORD_MESSAGE_RATE_LIMIT_PER_MINUTE` | Optional positive integer; authorized turns per guild/channel/user each minute (default: `30`) | | `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
| `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 ### 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. 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 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. 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.
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 <approval>` 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 ### Session retention and garbage collection

View File

@@ -1,27 +0,0 @@
# 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.

View File

@@ -1,36 +0,0 @@
# 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.

View File

@@ -1,37 +0,0 @@
# 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.

View File

@@ -1,57 +0,0 @@
# 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.

View File

@@ -1,28 +1,3 @@
# Tess Plugin Authoring # 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. 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.

View File

@@ -1,13 +1,5 @@
# Tess User Guide # 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 <approval>` 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. 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. 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.

View File

@@ -0,0 +1,238 @@
# Tess / Option 2 runtime-portability qualification — 2026-07-14
**Issue context:** #706#711 and runtime-neutral Mos follow-up #754
**Qualified revision:** `d0771835542d` (`origin/main` at review time)
**Reviewer/runtime:** Independent Pi lane requested as `openai-codex/gpt-5.6-sol:high`
**Runtime resolution note:** Mosaic warned that `gpt-5.6-sol` was not present in the provider model catalog and proceeded with it as a custom model ID. This warning was part of the original qualification log and is material provenance; downstream claims must not treat catalog recognition as verified.
**Verdict:** REQUEST CHANGES
**Evidence type:** Point-in-time qualification; later commits and PR #757 must be reviewed separately
## Purpose and provenance
This report preserves the complete independent qualification that was previously available only in `/tmp/tess-option2-qualification.log`. It distinguishes passing component tests from the missing operational proof required for identity-continuous Mos failover.
No credential values, OAuth tokens, Discord tokens, device codes, or auth-file contents are included. Commands and results are retained so another environment can reproduce or challenge the findings.
---
# 1. Verdict
## **REQUEST CHANGES**
The current Option 2 implementation is a useful portability foundation, but it is **not qualified against AC-TESS-01..11** and is not equivalent to true same-Mos-identity failover.
Primary blockers:
1. **AC-TESS-01/02:** The required `mosaic tess` command does not exist; only `mosaic interaction` is registered (`packages/mosaic/src/commands/interaction.ts:60`). The cross-surface test proves CLI enrollment followed by Discord approval/stop, not bidirectional Discord/CLI chat streaming.
2. **AC-TESS-04:** Fleet/tmux and Matrix providers are implemented as libraries but are not registered in the production gateway. `AgentModule` registers only Hermes (`apps/gateway/src/agent/agent.module.ts:34`).
3. **Mos handoff is not operational or durable:** Production uses `InMemoryInteractionCoordinationPort` (`apps/gateway/src/coord/coord.module.ts:18`), with no Mos-side consumer. Restart loses handoff ownership, idempotency, activity, and results.
4. **AC-TESS-06/10:** Restart tests are good local persistence tests, but no real connector/harness failover or exercised rollback exists. Rollback is documentation-only.
5. **AC-TESS-08:** The parity suite validates a selected shared intersection using mocked transports. Matrix is not production-wired and tmux drops the runtime message idempotency key before delivery.
6. **AC-TESS-09:** M5 qualification remains `not-started`; no live Discord, Matrix homeserver, tmux/Mos consumer, Claude Code/Pi/Codex failover, or deployment rollback was tested.
7. **PR #750 mismatch:** Its description promises send-error coverage as HTTP 400, but both gateway and TUI test use HTTP 403 (`packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts:25-34`).
### AC disposition
| AC | Result | Evidence |
|---|---|---|
| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E |
| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production |
| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed |
| 04 | **Fail** | No registered fleet provider or real Mos consumer |
| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited |
| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover |
| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent |
| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired |
| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent |
| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised |
| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved |
---
# 2. Exact test commands and results
Initial focused attempts failed before collection because this detached worktree had no dependencies:
```bash
pnpm --filter @mosaicstack/agent exec vitest run ...
```
Result: startup failure, `Cannot find module 'vitest/config'`.
Setup used:
```bash
corepack pnpm --store-dir /home/jarvis/.local/share/pnpm/store/v10 \
install --frozen-lockfile --ignore-scripts
```
Result: PASS, 1,240 packages linked.
```bash
corepack pnpm turbo run build \
--filter='@mosaicstack/gateway^...' \
--filter='@mosaicstack/mosaic^...'
```
Result: **17/17 dependency builds successful**.
### Focused suites
```bash
corepack pnpm --filter @mosaicstack/agent exec vitest run \
src/runtime-provider-parity.test.ts \
src/matrix-native-runtime-provider.test.ts \
src/tmux-fleet-runtime-provider.test.ts \
src/durable-session.test.ts \
src/hermes-runtime-provider.test.ts
```
Result: **5 files, 39/39 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
src/agent/durable-session.repository.test.ts \
src/__tests__/integration/tess-cross-surface.integration.test.ts \
src/plugin/discord-ingress.security.spec.ts \
src/coord/interaction-coordination.service.test.ts \
src/coord/interaction-coordination.routing.e2e.test.ts \
src/agent/hermes-runtime-reachability.e2e.test.ts
```
Result: **6 files, 36/36 tests passed**. PGlite close/reopen recovery passed in 504 ms.
```bash
corepack pnpm --filter @mosaicstack/mosaic exec vitest run \
src/fleet/matrix-native-runtime-transport.test.ts \
src/fleet/tess-service-profile.test.ts \
src/commands/interaction.test.ts \
src/tui/gateway-api.interaction-errors.test.ts
```
Result: **4 files, 15/15 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/coord exec vitest run \
src/__tests__/interaction-coordination.test.ts
```
Result: **1 file, 7/7 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
src/agent/interaction.controller.test.ts \
src/commands/command-authorization.service.spec.ts \
src/agent/__tests__/runtime-provider-registry.service.test.ts
```
Result: **3 files, 27/27 tests passed**.
Focused total: **124/124 tests passed** after dependency setup.
### Baselines
```bash
TURBO_FORCE=true corepack pnpm typecheck
```
Result: **42/42 tasks successful**.
```bash
TURBO_FORCE=true corepack pnpm lint
```
Result: **23/23 tasks successful**.
```bash
corepack pnpm format:check
```
Result: **PASS — all files matched Prettier style**.
```bash
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
-r mosaicstack/stack -n 1796
```
Result: **SUCCESS** at `d0771835542d`; all test, build, sanitization, typecheck, lint, format, and publish steps green.
No tracked files outside the pre-existing `.mosaic/orchestrator/*` launcher changes were modified.
---
# 3. Stale ledger inconsistencies
1. `docs/tess/MISSION-MANIFEST.md` still says:
- current milestone M1;
- progress 0/5;
- M2/M3/M5 not started.
2. `docs/tess/TASKS.md` says:
- M4-V failed;
- M4-W-001 and TESS-PLG-001 in progress;
- M5-V not started.
3. Provider issue state conflicts:
- #707#709 remain open although M1M3 rows are recorded done/pass.
- #710 and #711 are closed although M4-V failed and M5-V is not started.
4. M5 work was marked done despite depending on failed M4-V.
5. TESS-M3-002 says `mosaic tess` is done, but only `mosaic interaction` exists.
6. PR #750 removed stale service references from operational docs, but `docs/tess/TASKS.md` still contains `MosCoordinationService` in historical notes.
7. `docs/tess/MIGRATION-INVENTORY.md` remains an “initial inventory” with several capabilities marked `adapt`; `M5-MIGRATION-INVENTORY.md` marks grouped capabilities deferred/fail-closed. Neither supplies the complete owner/evidence matrix AC-TESS-10 requires.
8. TESS-M2-FUP-001 remains real: the unkeyed SHA-256 compatibility branch still exists at `durable-session.repository.ts:427-431`.
9. TESS-PLG-001 claims catalog registration was folded into W-001, but production evidence shows provider registration in the gateway—not a completed `packages/mosaic` plugin catalog.
---
# 4. Gap to true same-Mos-identity failover
Current code can relaunch the same roster name under another runtime and can rebind a durable interaction session to another provider/runtime ID. That is **replacement**, not identity-continuous failover.
Missing pieces:
- No canonical logical Mos identity independent of harness-native session IDs.
- No exclusive connector lease or monotonic fencing epoch; session rebinding is effectively last-write-wins.
- No stale-holder rejection preventing the old harness from continuing side effects.
- No normalized Claude Code/Pi/Codex checkpoint/import/export adapters.
- No durable Mos coordination transport or Mos consumer.
- No canonical handoff containing mission/task refs, git state, causal sequence, pending operations, capability requirements, and acknowledgements.
- No end-to-end receipt journal across connectors.
- Matrix has deterministic transaction IDs, but tmux delivery discards `RuntimeMessage.idempotencyKey`.
- No fault-injection test transferring Mos among Claude Code, Pi, and Codex and then rolling back.
---
# 5. Minimal follow-up issue decomposition
| Order | Issue | Minimum acceptance criteria |
|---|---|---|
| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs |
| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex |
| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript |
| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation |
| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published |
| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype |
## Generic gateway placement
Allowed topology:
```text
Discord / CLI / web
Mosaic Gateway: auth, tenant scope, policy, approvals, audit
IProviderAdapter / AgentRuntimeProvider
optional LiteLLM or Bifrost egress proxy
upstream provider
```
- **LiteLLM ChatGPT subscription OAuth:** research-only, opt-in, behind an adapter. Subscription credentials require explicit terms, revocation, scope, token-storage, and audit review. They must never become Mosaic identity or core configuration.
- **Bifrost:** virtual keys are downstream proxy credentials, not Mosaic principals. Budget and failover concepts may inform Mosaic routing, but tenant policy, authorization, and audit remain in Mosaic.
- Neither product may introduce schemas into Mosaic core, receive direct calls from channels/agents, or bypass `IProviderAdapter`/`AgentRuntimeProvider`.
- Mosaic should also correct its existing “all providers unhealthy → use one anyway” fallback behavior before adopting more automatic failover (`routing-engine.service.ts:204-212`).

View File

@@ -28,7 +28,6 @@ export default tseslint.config(
'apps/web/e2e/helpers/*.ts', 'apps/web/e2e/helpers/*.ts',
'apps/web/playwright.config.ts', 'apps/web/playwright.config.ts',
'apps/gateway/vitest.config.ts', 'apps/gateway/vitest.config.ts',
'plugins/discord/vitest.config.ts',
'packages/db/vitest.config.ts', 'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts', 'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts', 'packages/mosaic/vitest.config.ts',

View File

@@ -1,43 +0,0 @@
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<void>;
}
/** Adapter egress boundary used by the gateway after agent output is ready. */
export interface ChannelEgressPort {
send(egress: ChannelEgressDto): Promise<void>;
}
/** Shared lifecycle seam implemented by every official channel adapter. */
export interface OfficialChannelAdapter {
readonly name: string;
start(): Promise<void>;
stop(): Promise<void>;
/** Health is best-effort and never throws for ordinary disconnected state. */
health(): Promise<ChannelAdapterHealthDto>;
}

View File

@@ -1,97 +0,0 @@
/** 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<Record<string, ChannelMetadataValue>>;
}
/** 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<Record<string, ChannelAuthorizedPrincipalDto>>;
}
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;
}

View File

@@ -1,2 +0,0 @@
export * from './channel-adapter.js';
export * from './channel.dto.js';

View File

@@ -1,4 +1,3 @@
import type { ChannelAttachmentDto } from '../channel/index.js';
import type { import type {
CommandManifestPayload, CommandManifestPayload,
SlashCommandApprovalResultPayload, SlashCommandApprovalResultPayload,
@@ -74,7 +73,6 @@ export interface ChatMessagePayload {
provider?: string; provider?: string;
modelId?: string; modelId?: string;
agentId?: string; agentId?: string;
attachments?: readonly ChannelAttachmentDto[];
} }
/** Routing decision summary included in session:info for transparency */ /** Routing decision summary included in session:info for transparency */

View File

@@ -1,6 +1,5 @@
export const VERSION = '0.0.0'; export const VERSION = '0.0.0';
export * from './channel/index.js';
export * from './chat/index.js'; export * from './chat/index.js';
export * from './agent/index.js'; export * from './agent/index.js';
export * from './provider/index.js'; export * from './provider/index.js';

View File

@@ -1,67 +0,0 @@
# @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 <approval>` 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
```

View File

@@ -19,16 +19,13 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"lint": "eslint src", "lint": "eslint src",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --coverage", "test": "vitest run --passWithNoTests"
"test:coverage": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@mosaicstack/types": "workspace:^",
"discord.js": "^14.16.0", "discord.js": "^14.16.0",
"socket.io-client": "^4.8.0" "socket.io-client": "^4.8.0"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"tsx": "^4.0.0", "tsx": "^4.0.0",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^2.0.0" "vitest": "^2.0.0"

View File

@@ -1,950 +0,0 @@
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<string, FakeDiscordAttachment>;
}
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<string, FakeDiscordAttachment>;
hasThread: boolean;
thread: { id: string } | null;
startThread: ReturnType<typeof vi.fn>;
}
interface FakeGatewaySocket extends EventEmitter {
connected: boolean;
disconnect?: ReturnType<typeof vi.fn>;
}
interface FakeLifecycleClient extends EventEmitter {
user: { id: string; tag: string };
login: ReturnType<typeof vi.fn>;
destroy: ReturnType<typeof vi.fn>;
isReady(): boolean;
guilds: { cache: Map<string, object> };
channels: { cache: Map<string, object> };
}
interface DiscordPluginInternals {
client: {
user: { id: string };
isReady(): boolean;
channels?: {
cache: { get(id: string): { send(options: unknown): Promise<void> } | undefined };
};
};
socket: {
connected: boolean;
emit: ReturnType<typeof vi.fn>;
} | null;
conversationRoutes: Map<string, ChannelConversationRouteDto>;
handleDiscordMessage(message: FakeDiscordMessage): void | Promise<void>;
sendToDiscord(conversationId: string, text: string): Promise<void>;
}
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<string, object>() },
channels: { cache: new Map<string, object>() },
});
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<DiscordPluginConfig> = {},
): {
plugin: DiscordPlugin;
internals: DiscordPluginInternals;
emit: ReturnType<typeof vi.fn>;
} {
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<string, FakeDiscordAttachment>(),
hasThread: options.hasThread ?? existingThreadId !== undefined,
thread: existingThreadId ? { id: existingThreadId } : null,
startThread: vi.fn().mockResolvedValue({ id: options.createdThreadId ?? 'thread-created-001' }),
};
}
function emittedEnvelope(emit: ReturnType<typeof vi.fn>): 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<void>>().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<void> } | 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<FakeDiscordMessageOptions>) => {
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<FakeDiscordMessageOptions>) => {
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<FakeDiscordMessageOptions>) => {
const { internals, emit } = createPlugin();
const attachments = new Map<string, FakeDiscordAttachment>([
[
'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<void> } => ({ 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<void> } => ({ 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<void> } => ({ 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<void> } => ({ 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' });
});
});

View File

@@ -1,38 +1,10 @@
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto'; import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { ChannelDeliveryError } from '@mosaicstack/types'; import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
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'; import { io, type Socket } from 'socket.io-client';
export interface DiscordPluginDependencies {
ingressPort?: ChannelIngressPort;
client?: Client;
socketFactory?: (url: string, options: Parameters<typeof io>[1]) => Socket;
}
export interface DiscordPluginConfig { export interface DiscordPluginConfig {
token: string; token: string;
gatewayUrl: 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. */ /** Shared service credential injected by the approved secret mechanism. */
serviceToken: string; serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */ /** Which guild to bind to (single-guild only for v0.1.0). */
@@ -58,23 +30,13 @@ export interface DiscordInteractionUserBinding {
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding; export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
export interface DiscordInteractionBinding { export interface DiscordInteractionBinding {
/** Stable logical agent identity used in channel conversation routes. */
instanceId: string; instanceId: string;
/** Trusted gateway database agent-config ID selected for this binding. */
agentConfigId: string;
guildId: string; guildId: string;
channelId: string; channelId: string;
/** Pairing roster keyed by Discord user ID. */ /** Pairing roster keyed by Discord user ID. */
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>; pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
} }
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< const operationRoles: Readonly<
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]> Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
> = { > = {
@@ -128,7 +90,6 @@ export function parseDiscordInteractionBindings(
const candidate = binding as Partial<DiscordInteractionBinding>; const candidate = binding as Partial<DiscordInteractionBinding>;
if ( if (
!candidate.instanceId || !candidate.instanceId ||
!candidate.agentConfigId ||
!candidate.guildId || !candidate.guildId ||
!candidate.channelId || !candidate.channelId ||
!candidate.pairedUsers || !candidate.pairedUsers ||
@@ -169,7 +130,6 @@ export function parseDiscordInteractionBindings(
) as Record<string, DiscordInteractionPairing>; ) as Record<string, DiscordInteractionPairing>;
return { return {
instanceId: candidate.instanceId, instanceId: candidate.instanceId,
agentConfigId: candidate.agentConfigId,
guildId: candidate.guildId, guildId: candidate.guildId,
channelId: candidate.channelId, channelId: candidate.channelId,
pairedUsers, pairedUsers,
@@ -194,7 +154,6 @@ export interface DiscordAttachment {
name: string; name: string;
url: string; url: string;
contentType: string | null; contentType: string | null;
sizeBytes?: number;
} }
export interface DiscordIngressEnvelope { export interface DiscordIngressEnvelope {
@@ -270,37 +229,27 @@ export function verifyDiscordIngressEnvelope(
return envelope.payload; return envelope.payload;
} }
export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort { export class DiscordPlugin {
readonly name = 'discord';
private client: Client; private client: Client;
private socket: Socket | null = null; private socket: Socket | null = null;
/** Bounded last-authorized routes for response-target egress validation. */ /** Map Discord channel ID → Mosaic conversation ID. */
private conversationRoutes = new Map<string, ChannelConversationRouteDto>(); private channelConversations = new Map<string, string>();
/** Track in-flight responses to avoid duplicate streaming. */ /** Track in-flight responses to avoid duplicate streaming. */
private pendingResponses = new Map<string, string>(); private pendingResponses = new Map<string, string>();
private readonly messageRateWindows = new Map<string, number[]>();
private readonly threadRateWindows = new Map<string, number[]>();
constructor( constructor(private readonly config: DiscordPluginConfig) {
private readonly config: DiscordPluginConfig, this.client = new Client({
private readonly dependencies: DiscordPluginDependencies = {}, intents: [
) { GatewayIntentBits.Guilds,
this.client = GatewayIntentBits.GuildMessages,
dependencies.client ?? GatewayIntentBits.MessageContent,
new Client({ GatewayIntentBits.DirectMessages,
intents: [ ],
GatewayIntentBits.Guilds, });
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.DirectMessages,
],
});
} }
async start(): Promise<void> { async start(): Promise<void> {
const socketFactory = this.dependencies.socketFactory ?? io; this.socket = io(`${this.config.gatewayUrl}/chat`, {
this.socket = socketFactory(`${this.config.gatewayUrl}/chat`, {
auth: { discordServiceToken: this.config.serviceToken }, auth: { discordServiceToken: this.config.serviceToken },
transports: ['websocket'], transports: ['websocket'],
}); });
@@ -327,13 +276,11 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
this.socket.on('agent:end', (data: { conversationId: string }) => { this.socket.on('agent:end', (data: { conversationId: string }) => {
const text = this.pendingResponses.get(data.conversationId); const text = this.pendingResponses.get(data.conversationId);
this.pendingResponses.delete(data.conversationId);
if (text) { if (text) {
this.sendAgentResponse(data.conversationId, text).catch((err: unknown) => { this.pendingResponses.delete(data.conversationId);
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err); console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
}); });
} else {
this.conversationRoutes.delete(data.conversationId);
} }
}); });
@@ -341,33 +288,15 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
this.pendingResponses.set(data.conversationId, ''); this.pendingResponses.set(data.conversationId, '');
}); });
this.socket.on('error', (data: { conversationId?: unknown }) => { this.client.on('messageCreate', (message: DiscordMessage) =>
if (typeof data.conversationId !== 'string') return; this.handleDiscordMessage(message),
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', () => { this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`); console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
}); });
try { await this.client.login(this.config.token);
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<void> { async stop(): Promise<void> {
@@ -375,14 +304,6 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
await this.client.destroy(); await this.client.destroy();
} }
async health(): Promise<ChannelAdapterHealthDto> {
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: { async createProjectChannel(project: {
id: string; id: string;
name: string; name: string;
@@ -404,224 +325,77 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
topic: project.description ?? `Mosaic project: ${project.name}`, topic: project.description ?? `Mosaic project: ${project.name}`,
}); });
// A project channel has no logical-agent conversation until a configured this.channelConversations.set(channel.id, `discord-${channel.id}`);
// binding authorizes a message. Do not seed a legacy channel-only key.
return { channelId: channel.id }; return { channelId: channel.id };
} }
private handleDiscordMessage(message: DiscordMessage): void | Promise<void> { private handleDiscordMessage(message: DiscordMessage): void {
if (message.author.bot || !this.client.user || !message.guildId) return; if (message.author.bot || !this.client.user) return;
if (!message.guildId || !this.isAllowedMessage(message)) return;
const authorizationChannelId = this.authorizationChannelId(message);
if (!this.isAllowedMessage(message, authorizationChannelId)) return;
const isMention = message.mentions.has(this.client.user); const isMention = message.mentions.has(this.client.user);
const content = isMention if (!isMention) return;
? 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 rateKey = `${message.guildId}:${authorizationChannelId}:${message.author.id}`; const content = message.content
if ( .replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
!this.consumeRateLimit( .trim();
this.messageRateWindows, if (!content) return;
rateKey, if (!this.socket?.connected) {
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( console.error(
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`, `[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
); );
return; return;
} }
// Approval/stop commands act on the current durable session. They remain const channelId = message.channelId;
// at the current channel/thread target rather than creating a new topic. const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
const route = this.resolveConversationRoute( const bindingChannelId = parentChannelId ?? channelId;
message, const binding = resolveDiscordInteractionBinding(
binding, this.config.interactionBindings ?? [],
authorizationChannelId, message.guildId,
createThread, bindingChannelId,
message.author.id,
'send',
); );
if (route instanceof Promise) { if (!binding) return;
return route.then((resolved: ChannelConversationRouteDto): void | Promise<void> => const conversationId =
this.dispatchDiscordIngress(message, content, operation, binding, resolved), this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
); this.channelConversations.set(channelId, conversationId);
}
return this.dispatchDiscordIngress(message, content, operation, binding, route);
}
private dispatchDiscordIngress(
message: DiscordMessage,
content: string,
operation: 'send' | 'approve' | 'stop',
binding: DiscordInteractionBinding,
route: ChannelConversationRouteDto,
): void | Promise<void> {
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( const envelope = createDiscordIngressEnvelope(
{ {
correlationId: ingress.correlationId, correlationId: randomUUID(),
messageId: ingress.nativeMessageId, messageId: message.id,
guildId: String(ingress.message.metadata['guildId'] ?? ''), guildId: message.guildId,
channelId: ingress.route.authorizationChannelId, channelId: bindingChannelId,
userId: ingress.principal.channelUserId, userId: message.author.id,
conversationId: ingress.route.conversationId, conversationId,
content: ingress.message.content, content,
...(ingress.route.responseTarget.threadId threadId: parentChannelId ? channelId : undefined,
? { threadId: ingress.route.responseTarget.threadId } attachments: Array.from(message.attachments.values()).map((attachment) => ({
: {}),
attachments: ingress.message.attachments?.map((attachment) => ({
id: attachment.id, id: attachment.id,
name: attachment.name, name: attachment.name,
url: attachment.url, url: attachment.url,
contentType: attachment.mimeType, contentType: attachment.contentType,
...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}),
})), })),
}, },
this.config.serviceToken, this.config.serviceToken,
); );
socket.emit( this.socket.emit(
ingress.operation === 'approval.create' /^\/approve$/i.test(content)
? 'discord:approve' ? 'discord:approve'
: ingress.operation === 'session.stop' : content.startsWith('/stop ')
? 'discord:stop' ? 'discord:stop'
: 'message', : 'message',
envelope, envelope,
); );
} }
private authorizationChannelId(message: DiscordMessage): string { private isAllowedMessage(message: DiscordMessage): boolean {
// 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 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 ( return (
guildId !== null && guildId !== null &&
includesId(this.config.allowedGuildIds, guildId) && includesId(this.config.allowedGuildIds, guildId) &&
@@ -630,282 +404,31 @@ export class DiscordPlugin implements OfficialChannelAdapter, ChannelEgressPort
); );
} }
private resolveConversationRoute( private async sendToDiscord(conversationId: string, text: string): Promise<void> {
message: DiscordMessage, const channelId = Array.from(this.channelConversations.entries()).find(
binding: DiscordInteractionBinding, ([, convId]) => convId === conversationId,
authorizationChannelId: string, )?.[0];
createThread: boolean,
): ChannelConversationRouteDto | Promise<ChannelConversationRouteDto> { if (!channelId) {
if (authorizationChannelId !== message.channelId) { console.error(`[discord] No channel found for conversation ${conversationId}`);
return this.createConversationRoute( return;
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<string, number[]>,
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<void> {
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); const channel = this.client.channels.cache.get(channelId);
if (!channel || !('send' in channel)) { if (!channel || !('send' in channel)) {
throw new ChannelDeliveryError( console.error(
'destination_unavailable', `[discord] Channel ${channelId} not sendable for conversation ${conversationId}`,
`Discord destination is unavailable for conversation ${egress.route.conversationId}`,
); );
return;
} }
const chunks = this.chunkText(egress.message.content, 1900); for (const chunk of this.chunkText(text, 1900)) {
for (const [chunkIndex, chunk] of chunks.entries()) {
await this.sendChunkWithRetry(
channel as {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk,
egress.correlationId,
chunkIndex,
egress.route.conversationId,
);
}
}
private async sendChunkWithRetry(
channel: {
send(options: { content: string; nonce: string; enforceNonce: true }): Promise<unknown>;
},
chunk: string,
correlationId: string,
chunkIndex: number,
conversationId: string,
): Promise<void> {
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 { try {
await channel.send({ content: chunk, nonce, enforceNonce: true }); await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
return; } catch (err: unknown) {
} catch (error: unknown) { console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
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<void>((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<void> {
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<void> {
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[] { private chunkText(text: string, maxLength: number): string[] {

View File

@@ -4,15 +4,5 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
thresholds: {
lines: 85,
functions: 85,
branches: 85,
statements: 85,
},
},
}, },
}); });

41
pnpm-lock.yaml generated
View File

@@ -732,9 +732,6 @@ importers:
plugins/discord: plugins/discord:
dependencies: dependencies:
'@mosaicstack/types':
specifier: workspace:^
version: link:../../packages/types
discord.js: discord.js:
specifier: ^14.16.0 specifier: ^14.16.0
version: 14.25.1 version: 14.25.1
@@ -742,9 +739,6 @@ importers:
specifier: ^4.8.0 specifier: ^4.8.0
version: 4.8.3 version: 4.8.3
devDependencies: 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: tsx:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.21.0 version: 4.21.0
@@ -1175,11 +1169,11 @@ packages:
'@esbuild-kit/core-utils@3.3.2': '@esbuild-kit/core-utils@3.3.2':
resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==}
deprecated: 'Merged into tsx: https://tsx.hirok.io' deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild-kit/esm-loader@2.6.5': '@esbuild-kit/esm-loader@2.6.5':
resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==}
deprecated: 'Merged into tsx: https://tsx.hirok.io' deprecated: 'Merged into tsx: https://tsx.is'
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -2166,57 +2160,47 @@ packages:
'@mariozechner/pi-agent-core@0.63.1': '@mariozechner/pi-agent-core@0.63.1':
resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==} resolution: {integrity: sha512-h0B20xfs/iEVR2EC4gwiE8hKI1TPeB8REdRJMgV+uXKH7gpeIZ9+s8Dp9nX35ZR0QUjkNey2+ULk2DxQtdg14Q==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-agent-core@0.63.2': '@mariozechner/pi-agent-core@0.63.2':
resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==} resolution: {integrity: sha512-9QTS7ylcmoAIWXk0EVpwCCop3fK4NIqTAN8TiRuXvuKYx+wYmUJc+P5+RfehIZhwsy7g9O/rktz0c1YEUBFB0g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-agent-core@0.65.0': '@mariozechner/pi-agent-core@0.65.0':
resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==} resolution: {integrity: sha512-QCDqkgxvCkizCgJOl0aFekT1gURppznzuBIGXS8dXWZMour/xX6YF7chxX56mZ0p0DXkILM1ixf5jXYBfDsP5w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-agent-core instead going forward
'@mariozechner/pi-ai@0.63.1': '@mariozechner/pi-ai@0.63.1':
resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==} resolution: {integrity: sha512-wjgwY+yfrFO6a9QdAfjWpH7iSrDean6GsKDDMohNcLCy6PreMxHOZvNM0NwJARL1tZoZovr7ikAQfLGFZbnjsw==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-ai@0.63.2': '@mariozechner/pi-ai@0.63.2':
resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==} resolution: {integrity: sha512-EJNPyzeZeifTJmkD8PPYQmSO4P4h8kFCrhUqU4NvFUkug+GNYr954KlxhYnXH0f77MpdIEpf/O5zdDrYJQyafA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-ai@0.65.0': '@mariozechner/pi-ai@0.65.0':
resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==} resolution: {integrity: sha512-MsCsCHlHIlBYbg6jB2PJBeCNKbjzVZge7ddBNUJN2gsFY8sdjFh482+GB+r5Ou6k9Fnhi3nO779YDymo5+t89w==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-ai instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.63.1': '@mariozechner/pi-coding-agent@0.63.1':
resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==} resolution: {integrity: sha512-XSoMyLtuMA7ePK1UBWqSJ/BBdtBdJUHY9nbtnNyG6GeW7Gbgd+iqljIuwmAUf8wlYL981UIfYM/WIPQ6t/dIxw==}
engines: {node: '>=20.6.0'} engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-coding-agent@0.65.0': '@mariozechner/pi-coding-agent@0.65.0':
resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==} resolution: {integrity: sha512-IEBZ74n17w8NxnG/X2ixErsSYcvLm/h5WKALNbPgPWJZqvafNtJ0GcrCfLCS6RVIq2o+O/a2QwsbSI6bgJ6W/A==}
engines: {node: '>=20.6.0'} engines: {node: '>=20.6.0'}
deprecated: please use @earendil-works/pi-coding-agent instead going forward
hasBin: true hasBin: true
'@mariozechner/pi-tui@0.63.1': '@mariozechner/pi-tui@0.63.1':
resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==} resolution: {integrity: sha512-G5p+eh1EPkFCNaaggX6vRrqttnDscK6npgmEOknoCQXZtch8XNgh9Lf3VJ0A2lZXSgR7IntG5dfXHPH/Ki64wA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@mariozechner/pi-tui@0.65.0': '@mariozechner/pi-tui@0.65.0':
resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==} resolution: {integrity: sha512-P5Uuf4x1sTplMNQw8NrC1Hyz0N/tZq9kC6CDRkTT7rZuxZEeXl9uhKvlLEGigdKVOVrWnPE7ip0jrO81POYy3g==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
deprecated: please use @earendil-works/pi-tui instead going forward
'@matrix-org/matrix-sdk-crypto-nodejs@0.4.0': '@matrix-org/matrix-sdk-crypto-nodejs@0.4.0':
resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==} resolution: {integrity: sha512-+qqgpn39XFSbsD0dFjssGO9vHEP7sTyfs8yTpt8vuqWpUpF20QMwpCZi0jpYw7GxjErNTsMshopuo8677DfGEA==}
@@ -3931,7 +3915,6 @@ packages:
'@ungap/structured-clone@1.3.0': '@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@vitest/coverage-v8@2.1.9': '@vitest/coverage-v8@2.1.9':
resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==}
@@ -4112,7 +4095,6 @@ packages:
basic-ftp@5.2.0: basic-ftp@5.2.0:
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
deprecated: Security vulnerability fixed in 5.2.1, please upgrade
better-auth@1.5.5: better-auth@1.5.5:
resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==} resolution: {integrity: sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg==}
@@ -7105,7 +7087,6 @@ packages:
uuid@9.0.1: uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} 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 hasBin: true
validator@13.15.26: validator@13.15.26:
@@ -10996,24 +10977,6 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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': '@vitest/expect@2.1.9':
dependencies: dependencies:
'@vitest/spy': 2.1.9 '@vitest/spy': 2.1.9