import { createHash } from 'node:crypto'; import { Inject, Logger, Optional } from '@nestjs/common'; import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, type OnGatewayInit, ConnectedSocket, MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { verifyDiscordIngressEnvelope, parseDiscordInteractionBindings, resolveDiscordInteractionActorId, resolveDiscordInteractionBinding, type DiscordAttachment, type DiscordIngressEnvelope, type DiscordIngressPayload, } from '@mosaicstack/discord-plugin'; import type { Auth } from '@mosaicstack/auth'; import type { Brain } from '@mosaicstack/brain'; import { redactSensitiveContent } from '@mosaicstack/log'; import type { SetThinkingPayload, SlashCommandApprovalResultPayload, SlashCommandPayload, SystemReloadPayload, RoutingDecisionInfo, AbortPayload, ChannelAttachmentDto, } from '@mosaicstack/types'; import type { ConversationHistoryMessage } from '../agent/agent.service.js'; import { RUNTIME_PROVIDER_AUDIT_SINK, RuntimeProviderService, type RuntimeAuditSink, } from '../agent/runtime-provider-registry.service.js'; import { DurableSessionService } from '../agent/durable-session.service.js'; import { AUTH } from '../auth/auth.tokens.js'; import { scopeFromUser, type ActorTenantScope, type AuthenticatedUserLike, } from '../auth/session-scope.js'; import { BRAIN } from '../brain/brain.tokens.js'; import { CommandRegistryService } from '../commands/command-registry.service.js'; import { CommandExecutorService } from '../commands/command-executor.service.js'; import { CommandAuthorizationService } from '../commands/command-authorization.service.js'; import { RoutingEngineService } from '../agent/routing/routing-engine.service.js'; import { ChatRuntimeRouter } from './chat-runtime-router.js'; import { ownConversation, verifyDiscordIngress, type LegacyRuntimeEvent, type LegacyRuntimeStream, type LegacySocketTurnLease, type VerifiedDiscordTurnLease, } from './chat-runtime.js'; import { v4 as uuid } from 'uuid'; import { ChatSocketMessageDto } from './chat.dto.js'; import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js'; import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js'; /** Per-client state tracking streaming accumulation for persistence. */ interface ClientSession { clientId: string; conversationId: string; /** Server-derived egress channel id (`websocket:`) this turn streams over. */ channelId: string; /** The prepared runtime turn; disposing it removes the listener and channel. */ lease: LegacySocketTurnLease | VerifiedDiscordTurnLease; /** Accumulated assistant response text for the current turn. */ assistantText: string; /** Tool calls observed during the current turn. */ toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>; /** Tool calls in-flight (started but not ended yet). */ pendingToolCalls: Map; /** Server-derived owner/tenant scope for this socket's conversation attachment. */ scope: ActorTenantScope; } /** * Per-conversation model overrides set via /model command (M4-007). * Keyed by conversationId, value is the model name to use. */ const modelOverrides = new Map(); /** * Task 5 (G3): commands whose effect is runtime-independent — they operate on gateway/system * state rather than an embedded chat session — and therefore stay available under pi-rpc. Every * other command is an embedded-session command and is fixed-"unsupported" under pi-rpc, failing * closed before the executor. Kept as an explicit allowlist so adding a runtime-independent * command is a deliberate edit, never an accidental fall-through. */ const RUNTIME_INDEPENDENT_COMMANDS: ReadonlySet = new Set(['reload']); const MAX_REDACTION_BUFFER_LENGTH = 8_192; const MAX_CHANNEL_ATTACHMENTS = 10; const MAX_ATTACHMENT_METADATA_BYTES = 16_384; const MAX_ATTACHMENT_ID_LENGTH = 128; const MAX_ATTACHMENT_NAME_LENGTH = 255; const MAX_ATTACHMENT_URL_LENGTH = 2_048; const MAX_ATTACHMENT_MIME_LENGTH = 255; function isSafeAttachmentUrl(value: string): boolean { if (value.length === 0 || value.length > MAX_ATTACHMENT_URL_LENGTH) return false; try { const url = new URL(value); return ( url.protocol === 'https:' && !url.username && !url.password && !url.hash && url.search.length === 0 ); } catch { return false; } } function hasValidAttachmentBounds(value: { id: string; name: string; url: string; sizeBytes?: number; }): boolean { return ( value.id.length > 0 && value.id.length <= MAX_ATTACHMENT_ID_LENGTH && value.name.length > 0 && value.name.length <= MAX_ATTACHMENT_NAME_LENGTH && isSafeAttachmentUrl(value.url) && (value.sizeBytes === undefined || (Number.isFinite(value.sizeBytes) && value.sizeBytes >= 0)) ); } function isDiscordAttachment(value: unknown): value is DiscordAttachment { if (typeof value !== 'object' || value === null) return false; const attachment = value as Partial; return ( typeof attachment.id === 'string' && typeof attachment.name === 'string' && typeof attachment.url === 'string' && (attachment.contentType === null || (typeof attachment.contentType === 'string' && attachment.contentType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && hasValidAttachmentBounds(attachment as DiscordAttachment) ); } function hasValidAttachmentArray(value: unknown, guard: (attachment: unknown) => boolean): boolean { return ( Array.isArray(value) && value.length <= MAX_CHANNEL_ATTACHMENTS && JSON.stringify(value).length <= MAX_ATTACHMENT_METADATA_BYTES && value.every(guard) ); } function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope { if (typeof value !== 'object' || value === null) return false; const envelope = value as { payload?: unknown; signature?: unknown }; if ( typeof envelope.signature !== 'string' || typeof envelope.payload !== 'object' || envelope.payload === null ) { return false; } const payload = envelope.payload as Record; return ( [ payload['correlationId'], payload['messageId'], payload['guildId'], payload['channelId'], payload['userId'], payload['conversationId'], payload['content'], ].every((field: unknown): boolean => typeof field === 'string') && (payload['threadId'] === undefined || typeof payload['threadId'] === 'string') && (payload['attachments'] === undefined || hasValidAttachmentArray(payload['attachments'], isDiscordAttachment)) ); } function isChannelAttachment(value: unknown): value is ChannelAttachmentDto { if (typeof value !== 'object' || value === null) return false; const attachment = value as Partial; return ( typeof attachment.id === 'string' && typeof attachment.name === 'string' && typeof attachment.url === 'string' && (attachment.mimeType === null || (typeof attachment.mimeType === 'string' && attachment.mimeType.length <= MAX_ATTACHMENT_MIME_LENGTH)) && (attachment.sizeBytes === undefined || typeof attachment.sizeBytes === 'number') && hasValidAttachmentBounds(attachment as ChannelAttachmentDto) ); } function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto { if (typeof value !== 'object' || value === null) return false; const payload = value as { content?: unknown; conversationId?: unknown; attachments?: unknown; }; return ( typeof payload.content === 'string' && (payload.conversationId === undefined || typeof payload.conversationId === 'string') && (payload.attachments === undefined || hasValidAttachmentArray(payload.attachments, isChannelAttachment)) ); } @WebSocketGateway({ cors: { origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000', }, namespace: '/chat', }) export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { @WebSocketServer() server!: Server; private readonly logger = new Logger(ChatGateway.name); private readonly clientSessions = new Map(); /** Raw stream fragments are kept in memory only until they are safe to redact and emit. */ private readonly textEgressBuffers = new Map(); private readonly thinkingEgressBuffers = new Map(); private readonly overflowedEgress = new Set(); private readonly discordReplayProtector = new DiscordReplayProtector(); constructor( private readonly runtime: ChatRuntimeRouter, @Inject(AUTH) private readonly auth: Auth, @Inject(BRAIN) private readonly brain: Brain, @Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService, @Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService, // Vestigial arity-only slot: the router is the sole execution authority and the gateway // never routes. The union type erases to `Object`, so with `@Optional()` and no `@Inject` // this resolves to `null` in every graph (production and test) and is never invoked. @Optional() private readonly routingEngine: RoutingEngineService | null = null, @Optional() @Inject(CommandAuthorizationService) private readonly commandAuthorization: CommandAuthorizationService | null = null, @Optional() @Inject(RuntimeProviderService) private readonly runtimeRegistry: RuntimeProviderService | null = null, @Optional() @Inject(DurableSessionService) private readonly durableSessions: DurableSessionService | null = null, @Optional() @Inject(RUNTIME_PROVIDER_AUDIT_SINK) private readonly runtimeAudit: RuntimeAuditSink | null = null, ) {} afterInit(): void { this.logger.log('Chat WebSocket gateway initialized'); } async handleConnection(client: Socket): Promise { const serviceToken = client.handshake.auth['discordServiceToken']; if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) { client.data.discordService = true; this.logger.log(`Authenticated Discord service connected: ${client.id}`); return; } const session = await validateSocketSession(client.handshake.headers, this.auth); if (!session) { this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`); client.disconnect(); return; } client.data.user = session.user; client.data.session = session.session; this.logger.log(`Client connected: ${client.id}`); client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() }); // Send-protocol advertisement (Task 5): a conversation id or harness selection never proves the // connected Gateway handles a given wire event, so after BetterAuth authentication and // user/session assignment advertise — exactly once, bound to this connection — which send event // the browser may use. Legacy mode handles `message` (`legacy-message`); `pi-rpc` fails that // handler closed and its authenticated `turn:send` handler lands in Task 15, so it advertises // `unavailable` and never `turn-send`. Capability is routing information, never authorization. client.emit('chat:send-capability', { protocol: this.runtime.runtimeMode === 'pi-rpc' ? 'unavailable' : 'legacy-message', connectionId: client.id, }); } handleDisconnect(client: Socket): void { this.logger.log(`Client disconnected: ${client.id}`); for (const [key, session] of this.clientSessions) { if (session.clientId !== client.id) continue; // The lease owns listener + channel teardown; dispose is idempotent and non-throwing. void session.lease.dispose(); this.clientSessions.delete(key); this.textEgressBuffers.delete(key); this.thinkingEgressBuffers.delete(key); this.overflowedEgress.delete(`${key}:agent:text`); this.overflowedEgress.delete(`${key}:agent:thinking`); } } private clientConversationKey(client: Pick, conversationId: string): string { return `${client.id}\u0000${conversationId}`; } private getClientScope(client: Socket): ActorTenantScope | null { const user = client.data.user as AuthenticatedUserLike | undefined; if (!user?.id) return null; return scopeFromUser(user); } private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string { return `${scope.tenantId}:${scope.userId}:${conversationId}`; } private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean { return a.userId === b.userId && a.tenantId === b.tenantId; } @SubscribeMessage('message') async handleMessage( @ConnectedSocket() client: Socket, @MessageBody() rawData: unknown, ): Promise { // Verified-Discord ingress and browser turns are distinct trust surfaces: the service flag // is set only after handshake-token auth at handleConnection. A forged envelope from a // non-service socket falls through to the browser path, where it is rejected as malformed. if (client.data.discordService) { await this.handleVerifiedDiscordSend(client, rawData); return; } await this.handleBrowserSend(client, rawData); } private async handleBrowserSend(client: Socket, rawData: unknown): Promise { // Fail a legacy browser turn closed under pi-rpc BEFORE parsing the payload — never fall back. if (this.runtime.runtimeMode === 'pi-rpc') { const conversationId = typeof rawData === 'object' && rawData !== null && typeof (rawData as { conversationId?: unknown }).conversationId === 'string' ? (rawData as { conversationId: string }).conversationId : undefined; client.emit('error', { conversationId, code: 'runtime_unsupported', retryable: false, error: 'Browser chat is not available on this deployment.', }); return; } if (!isChatSocketMessage(rawData)) { this.logger.warn(`Rejected malformed chat message from ${client.id}`); return; } const data = rawData; const suppliedConversationId = data.conversationId; const conversationId = suppliedConversationId ?? uuid(); const scope = this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } // Durable ownership admission BEFORE any runtime/listener/channel effect (security fix, // finding 1). A browser-supplied conversation id must resolve to THIS socket's own durable // owner; a missing row and a row owned by another user both fail closed here, so runtime state // is never allocated under an unowned conversation. A send that omits the id is the distinct // server-minted-new path: the durable record is created first and a creation failure fails // closed. Both rejections collapse to conversation_unavailable with zero downstream effects. if ( !(await this.admitBrowserConversation(suppliedConversationId, conversationId, scope.userId)) ) { client.emit('error', { conversationId, code: 'conversation_unavailable', retryable: false, error: 'That conversation is not available.', }); return; } this.logger.log(`Message from ${client.id} in conversation ${conversationId}`); // Dispose any prior turn on this exact channel BEFORE preparing the next: prepare re-adds the // same server-derived channel id, so disposing after would tear down the new subscription. const key = this.clientConversationKey(client, conversationId); await this.disposeExistingSession(key); const stream: LegacyRuntimeStream = { channelId: `websocket:${client.id}`, onEvent: (event: LegacyRuntimeEvent): void => this.relayEvent(client, conversationId, event), }; const prepared = await this.runtime.prepareLegacySocketTurn( ownConversation(conversationId, scope), { content: data.content, ...(data.provider ? { provider: data.provider } : {}), ...(data.modelId ? { modelId: data.modelId } : {}), ...(data.agentId ? { agentId: data.agentId } : {}), ...(data.attachments ? { attachments: data.attachments } : {}), }, stream, ); if (!prepared.ok) { client.emit('error', { conversationId, code: prepared.code, retryable: prepared.retryable, error: 'Failed to start agent session. Please try again.', }); return; } this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); // Persist the user turn BEFORE acknowledging or dispatching. If persistence fails, abort the // turn: tear down the just-prepared lease and surface the failure — never ack-then-lose. const persisted = await this.persistUserMessage( conversationId, scope.userId, data.content, data.attachments, ); if (!persisted) { await this.disposeExistingSession(key); client.emit('error', { conversationId, code: 'persist_failed', retryable: true, error: 'Your message could not be saved. Please try again.', }); return; } client.emit('session:info', { conversationId, ...prepared.value.presentation }); client.emit('message:ack', { conversationId, messageId: uuid() }); const dispatched = await prepared.value.dispatch(); if (!dispatched.ok) { client.emit('error', { conversationId, error: 'The agent failed to process your message. Please try again.', }); } } private async handleVerifiedDiscordSend(client: Socket, rawData: unknown): Promise { if (!isDiscordIngressEnvelope(rawData)) { this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`); return; } const ingress = this.resolveDiscordIngress(client, rawData, 'send', false); if (!ingress) return; const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID']; if (!discordServiceUserId) { this.logger.warn( `Rejected Discord ingress without configured service owner from ${client.id}`, ); return; } const binding = this.discordBindingFor(ingress, 'send'); if (!binding) { this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); return; } const scope: ActorTenantScope = { userId: discordServiceUserId, tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId, }; const conversationId = ingress.conversationId; const attachments = ingress.attachments?.map( (attachment): ChannelAttachmentDto => ({ id: attachment.id, name: attachment.name, url: attachment.url, mimeType: attachment.contentType, ...(attachment.sizeBytes !== undefined ? { sizeBytes: attachment.sizeBytes } : {}), }), ); // Reconcile the verified binding against the durable agent record BEFORE claiming the message // id (security fix, finding 3). The configured agent must exist and its record must match the // binding EXACTLY on BOTH id and name — a record whose id differs from the binding's // agentConfigId (an aliased/substituted lookup) is rejected as firmly as a name mismatch. A // missing record, an id mismatch, a name mismatch, or a lookup error rejects the turn WITHOUT // consuming the replay claim, so a corrected retry is still admitted. The branded identity is // derived from the record (id + name), never from the raw binding strings — an unreconciled // binding must not execute a default or different embedded agent under a verified label. let configuredAgent: { readonly agentConfigId: string; readonly instanceId: string }; try { const record = await this.brain.agents.findById(binding.agentConfigId); if (!record || record.id !== binding.agentConfigId || record.name !== binding.instanceId) { this.logger.warn( `Rejected Discord ingress: configured agent not reconciled binding=${binding.agentConfigId} instance=${binding.instanceId}`, ); return; } configuredAgent = { agentConfigId: record.id, instanceId: record.name }; } catch (err) { this.logger.error( `Discord configured-agent reconciliation failed binding=${binding.agentConfigId}`, err instanceof Error ? err.stack : String(err), ); return; } // Atomic replay claim LAST — after the configured-identity, binding, forced-scope, and // attachment checks above have all passed, and immediately before the first effect (existing // session teardown + dispatch). An envelope rejected by any earlier gate consumes no claim, so // a corrected byte-identical retry dispatches once; once a turn commits here, a true duplicate // finds the claim taken and fails closed with no additional dispatch/persist/ack. if (!this.discordReplayProtector.claim(ingress.messageId)) { this.logger.warn( `Rejected replayed Discord message=${ingress.messageId} correlation=${ingress.correlationId}`, ); return; } this.logger.log( `Message from ${client.id} in conversation ${conversationId} correlation=${ingress.correlationId}`, ); const key = this.clientConversationKey(client, conversationId); await this.disposeExistingSession(key); const stream: LegacyRuntimeStream = { channelId: `websocket:${client.id}`, onEvent: (event: LegacyRuntimeEvent): void => this.relayEvent(client, conversationId, event), }; // The configured agent is the record reconciled above (finding 3): the embedded runtime rechecks // scope, refuses to reuse a same-scope session under a different identity, and mints under this // exact reconciled identity. const context = verifyDiscordIngress({ conversationId, scope, configuredAgent, content: ingress.content, ...(attachments && attachments.length > 0 ? { attachments } : {}), correlationId: ingress.correlationId, discordMessageId: ingress.messageId, discordUserId: ingress.userId, }); const prepared = await this.runtime.dispatchVerifiedDiscordIngress(context, stream); if (!prepared.ok) { client.emit('error', { conversationId, code: prepared.code, retryable: prepared.retryable, error: 'Failed to start agent session. Please try again.', }); return; } this.registerClientSession(client, conversationId, stream.channelId, prepared.value, scope); // Persist BEFORE acknowledging or dispatching; on persistence failure abort the verified turn // (tear down the lease, surface the error) rather than ack-then-lose the Discord message. const persisted = await this.persistUserMessage( conversationId, scope.userId, ingress.content, attachments, { correlationId: ingress.correlationId, discordMessageId: ingress.messageId, discordUserId: ingress.userId, }, ); if (!persisted) { await this.disposeExistingSession(key); client.emit('error', { conversationId, code: 'persist_failed', retryable: true, correlationId: ingress.correlationId, discordMessageId: ingress.messageId, discordUserId: ingress.userId, error: 'Your message could not be saved. Please try again.', }); return; } client.emit('session:info', { conversationId, ...prepared.value.presentation }); client.emit('message:ack', { conversationId, messageId: uuid(), correlationId: ingress.correlationId, discordMessageId: ingress.messageId, discordUserId: ingress.userId, }); const dispatched = await prepared.value.dispatch(); if (!dispatched.ok) { client.emit('error', { conversationId, error: 'The agent failed to process your message. Please try again.', }); } } private registerClientSession( client: Socket, conversationId: string, channelId: string, lease: LegacySocketTurnLease | VerifiedDiscordTurnLease, scope: ActorTenantScope, ): void { this.clientSessions.set(this.clientConversationKey(client, conversationId), { clientId: client.id, conversationId, channelId, lease, assistantText: '', toolCalls: [], pendingToolCalls: new Map(), scope, }); } private async disposeExistingSession(key: string): Promise { const existing = this.clientSessions.get(key); if (!existing) return; await existing.lease.dispose(); this.clientSessions.delete(key); } private async persistUserMessage( conversationId: string, userId: string | undefined, content: string, attachments: readonly ChannelAttachmentDto[] | undefined, discord?: { correlationId: string; discordMessageId: string; discordUserId: string }, ): Promise { if (!userId) return true; await this.ensureConversation(conversationId, userId); try { const saved = await this.brain.conversations.addMessage( { conversationId, role: 'user', content: redactSensitiveContent(content).content, metadata: { timestamp: new Date().toISOString(), ...(discord ? { correlationId: discord.correlationId, discordMessageId: discord.discordMessageId, discordUserId: discord.discordUserId, } : {}), ...(attachments && attachments.length > 0 ? { channelAttachments: attachments.map( (attachment): ChannelAttachmentDto => ({ ...attachment, name: redactSensitiveContent(attachment.name).content, url: redactSensitiveContent(attachment.url).content, }), ), } : {}), classifications: redactSensitiveContent(content).classifications, }, }, userId, ); // A nullish result is durable persistence failure, not success: `addMessage` returns // undefined when the parent conversation is missing or owned by another user, inserting no // row. Treat it exactly like a thrown error so the caller tears down the lease and surfaces // `persist_failed` — never ack/dispatch/prompt on a turn whose user message was not stored. if (saved === undefined || saved === null) { this.logger.error( `User message not persisted for conversation=${conversationId}: no durable record (missing or foreign conversation owner)`, ); return false; } return true; } catch (err) { this.logger.error( `Failed to persist user message for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), ); return false; } } @SubscribeMessage('set:thinking') handleSetThinking( @ConnectedSocket() client: Socket, @MessageBody() data: SetThinkingPayload, ): void { const scope = this.getClientScope(client); if (!scope) { client.emit('error', { conversationId: data.conversationId, error: 'Authenticated user scope is required.', }); return; } const result = this.runtime.setLegacyThinking( ownConversation(data.conversationId, scope), data.level, ); if (!result.ok) { if (result.code === 'thinking_level_invalid') { client.emit('error', { conversationId: data.conversationId, error: `Invalid thinking level "${data.level}". Available: ${result.availableThinkingLevels.join(', ')}`, }); } else if (result.code === 'conversation_unavailable') { client.emit('error', { conversationId: data.conversationId, error: 'No active session for this conversation.', }); } else { client.emit('error', { conversationId: data.conversationId, error: 'Failed to set thinking level.', }); } return; } this.logger.log( `Thinking level set to "${data.level}" for conversation ${data.conversationId}`, ); client.emit('session:info', { conversationId: data.conversationId, ...result.value }); } @SubscribeMessage('abort') async handleAbort( @ConnectedSocket() client: Socket, @MessageBody() data: AbortPayload, ): Promise { const conversationId = data.conversationId; this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`); const scope = this.getClientScope(client); if (!scope) { client.emit('error', { conversationId, error: 'Authenticated user scope is required.' }); return; } const result = await this.runtime.abortLegacyTurn(ownConversation(conversationId, scope)); if (!result.ok) { client.emit('error', { conversationId, error: result.code === 'conversation_unavailable' ? 'No active session to abort.' : 'Failed to abort the agent operation.', }); return; } this.logger.log(`Agent session ${conversationId} aborted successfully`); } @SubscribeMessage('command:execute') async handleCommandExecute( @ConnectedSocket() client: Socket, @MessageBody() payload: SlashCommandPayload, ): Promise { const scope = this.getClientScope(client); if (!scope) { client.emit('command:result', { command: payload.command, conversationId: payload.conversationId, success: false, message: 'Authenticated user scope is required.', }); return; } // Task 5 (G3): under pi-rpc there is no embedded chat session, so embedded slash-commands are // unsupported. Fail closed BEFORE the executor — never fall back to embedded execution — while // runtime-independent audited system commands (e.g. /reload) still pass through. if ( this.runtime.runtimeMode === 'pi-rpc' && !RUNTIME_INDEPENDENT_COMMANDS.has(payload.command) ) { client.emit('command:result', { command: payload.command, conversationId: payload.conversationId, success: false, message: 'Slash commands are not available on this deployment.', }); return; } const result = await this.commandExecutor.execute(payload, scope); client.emit('command:result', result); } @SubscribeMessage('command:approve') async handleCommandApproval( @ConnectedSocket() client: Socket, @MessageBody() payload: SlashCommandPayload, ): Promise { const scope = this.getClientScope(client); const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null; const result: SlashCommandApprovalResultPayload = approval ? { command: payload.command, conversationId: payload.conversationId, success: true, approvalId: approval.approvalId, expiresAt: approval.expiresAt, } : { command: payload.command, conversationId: payload.conversationId, success: false, message: 'Not authorized to approve this command.', }; client.emit('command:approval', result); } broadcastReload(payload: SystemReloadPayload): void { this.server.emit('system:reload', payload); this.logger.log('Broadcasted system:reload to all connected clients'); } /** * Set a per-conversation model override (M4-007 / M5-002). * When set, the routing engine is bypassed and the specified model is used. * Pass null to clear the override and resume automatic routing. * M5-005: Emits session:info to clients subscribed to this conversation when a model is set. * M5-007: Records a model switch in session metrics. */ setModelOverride( conversationId: string, modelName: string | null, scope: ActorTenantScope, ): void { const key = this.modelOverrideKey(conversationId, scope); if (modelName) { modelOverrides.set(key, modelName); this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`); // M5-002: Update the live session's modelId so session:info reflects the new model immediately this.runtime.applyLegacyModelOverride(ownConversation(conversationId, scope), modelName); // M5-005: Broadcast session:info to all clients subscribed to this conversation this.broadcastSessionInfo(conversationId, scope); } else { modelOverrides.delete(key); this.logger.log(`Model override cleared: conversation=${conversationId}`); } } /** * Return the active model override for a conversation, or undefined if none. */ getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined { return modelOverrides.get(this.modelOverrideKey(conversationId, scope)); } /** * M5-005: Broadcast session:info to all clients currently subscribed to a conversation. * Called on model or agent switch to ensure the TUI TopBar updates immediately. */ broadcastSessionInfo( conversationId: string, scope: ActorTenantScope, extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo }, ): void { const result = this.runtime.readLegacySessionPresentation( ownConversation(conversationId, scope), ); if (!result.ok) return; const resolvedAgentName = extra?.agentName ?? result.value.agentName; const payload = { conversationId, ...result.value, ...(resolvedAgentName ? { agentName: resolvedAgentName } : {}), ...(extra?.routingDecision ? { routingDecision: extra.routingDecision } : {}), }; // Emit to all clients currently subscribed to this conversation for (const session of this.clientSessions.values()) { if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) { const socket = this.server.sockets.sockets.get(session.clientId); if (socket?.connected) { socket.emit('session:info', payload); } } } } /** * Ensure a conversation record exists in the DB. * Creates it if absent — safe to call concurrently since a duplicate insert * would fail on the PK constraint and be caught here. */ @SubscribeMessage('discord:approve') async handleDiscordApproval( @ConnectedSocket() client: Socket, @MessageBody() envelope: DiscordIngressEnvelope, ): Promise { if (!client.data.discordService) return; const ingress = this.resolveDiscordIngress(client, envelope, 'approve'); const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? ''); const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); if ( !ingress || !isApprovalCommand || !tenantId || !this.commandAuthorization || !this.durableSessions ) return; const binding = this.discordBindingFor(ingress, 'approve'); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); const agentName = binding?.instanceId; if (!actorId || !agentName) { this.logger.warn( `Rejected Discord approval without a matching runtime agent from ${client.id}`, ); client.emit('discord:approval', { correlationId: ingress.correlationId, success: false, approvalId: undefined, expiresAt: undefined, }); return; } let snapshot; try { snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, { actorScope: { userId: actorId, tenantId }, channelId: ingress.channelId, correlationId: ingress.correlationId, }); } catch { client.emit('discord:approval', { correlationId: ingress.correlationId, success: false, approvalId: undefined, expiresAt: undefined, }); return; } if (snapshot.identity.agentName !== agentName) { client.emit('discord:approval', { correlationId: ingress.correlationId, success: false, approvalId: undefined, expiresAt: undefined, }); return; } const approval = await this.commandAuthorization.createRuntimeTerminationApproval({ providerId: snapshot.identity.providerId, sessionId: snapshot.identity.runtimeSessionId, actorId, tenantId, channelId: ingress.channelId, correlationId: this.discordRuntimeActionCorrelation( binding.instanceId, ingress, snapshot.identity.providerId, snapshot.identity.runtimeSessionId, ), agentName, }); if (!approval) { await this.runtimeAudit?.record({ providerId: snapshot.identity.providerId, operation: 'session.terminate', outcome: 'denied', actorId, tenantId, channelId: ingress.channelId, correlationId: this.discordRuntimeActionCorrelation( binding.instanceId, ingress, snapshot.identity.providerId, snapshot.identity.runtimeSessionId, ), resourceId: snapshot.identity.runtimeSessionId, errorCode: 'policy_denied', }); } client.emit('discord:approval', { correlationId: ingress.correlationId, success: approval !== null, approvalId: approval?.approvalId, expiresAt: approval?.expiresAt, }); } @SubscribeMessage('discord:stop') async handleDiscordStop( @ConnectedSocket() client: Socket, @MessageBody() envelope: DiscordIngressEnvelope, ): Promise { if (!client.data.discordService) return; const ingress = this.resolveDiscordIngress(client, envelope, 'stop'); const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1]; const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim(); if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions) return; const binding = this.discordBindingFor(ingress, 'stop'); const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId); if (!actorId) return; try { const context = { actorScope: { userId: actorId, tenantId }, channelId: ingress.channelId, correlationId: ingress.correlationId, }; const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context); if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch'); // RuntimeProviderService consumes the durable approval exactly once using the // provisioned approving-admin identity, never the Discord service account. await this.runtimeRegistry.terminate( snapshot.identity.providerId, snapshot.identity.runtimeSessionId, approvalRef, { ...context, correlationId: this.discordRuntimeActionCorrelation( binding.instanceId, ingress, snapshot.identity.providerId, snapshot.identity.runtimeSessionId, ), }, ); client.emit('discord:stop', { correlationId: ingress.correlationId, success: true }); } catch { client.emit('discord:stop', { correlationId: ingress.correlationId, success: false }); } } /** * Correlates the immutable termination target rather than either Discord message. * Approval and stop are distinct ingress events, but must consume the same seven-field action. */ private discordRuntimeActionCorrelation( instanceId: string, ingress: DiscordIngressPayload, providerId: string, sessionId: string, ): string { const target = [ instanceId, ingress.guildId, ingress.channelId, ingress.conversationId, providerId, sessionId, ]; return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`; } private resolveDiscordIngress( client: Socket, envelope: DiscordIngressEnvelope, operation: 'send' | 'approve' | 'stop' = 'send', claimReplay = true, ): DiscordIngressPayload | null { const payload = verifyDiscordIngressEnvelope( envelope, process.env['DISCORD_SERVICE_TOKEN'] ?? '', { guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'), channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'), userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'), }, ); if (!payload) { this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`); return null; } try { const binding = this.discordBindingFor(payload, operation); if (!binding) { this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`); return null; } const expectedConversationId = `${binding.instanceId}:discord:${payload.threadId ?? payload.channelId}`; if (payload.conversationId !== expectedConversationId) { this.logger.warn( `Rejected Discord ingress for a different logical agent from ${client.id}`, ); return null; } } catch { this.logger.warn( `Rejected Discord ingress without valid binding configuration from ${client.id}`, ); return null; } // The SEND path passes `claimReplay: false` and claims the message itself only after the // configured-identity, binding, forced-scope, and attachment checks succeed — immediately // before its first effect — so a SEND rejected by one of those later gates burns no claim and // a corrected retry is not mistaken for a replay. The approve/stop paths have no such // post-resolve gates, so they claim here, at the moment the envelope is fully verified. if (claimReplay && !this.discordReplayProtector.claim(payload.messageId)) { this.logger.warn( `Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`, ); return null; } return payload; } private discordBindingFor( payload: DiscordIngressPayload, operation: 'send' | 'approve' | 'stop', ) { return resolveDiscordInteractionBinding( parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']), payload.guildId, payload.channelId, payload.userId, operation, ); } private readDiscordAllowlist(name: string): string[] { return (process.env[name] ?? '') .split(',') .map((id: string): string => id.trim()) .filter((id: string): boolean => id.length > 0); } /** * Durable ownership admission for a browser send, run BEFORE any runtime/listener/channel effect * (security fix, finding 1). * * A supplied conversation id must durably resolve to this socket's own user: `findById` scopes by * owner, so a missing row and a row owned by another user both return undefined and admission * fails (returns false) with zero runtime effect. A send that omits the id * (`suppliedConversationId === undefined`) is the server-minted-new path — the durable record is * created first and any creation failure fails closed. Never allocate runtime under a conversation * this socket does not own or could not create. */ private async admitBrowserConversation( suppliedConversationId: string | undefined, conversationId: string, userId: string, ): Promise { try { if (suppliedConversationId !== undefined) { const owned = await this.brain.conversations.findById(suppliedConversationId, userId); return owned !== undefined; } // Minting a new conversation must actually yield the durable record we asked for before any // runtime effect runs (finding 1). A `create` that resolves nullish, or returns a record that // is not this exact id owned by this user, is a persistence failure — fail closed so the caller // never dispatches/persists against an unpersisted or mis-scoped conversation. const created = await this.brain.conversations.create({ id: conversationId, userId }); return created != null && created.id === conversationId && created.userId === userId; } catch (err) { this.logger.error( `Conversation admission failed for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), ); return false; } } private async ensureConversation(conversationId: string, userId: string): Promise { try { const existing = await this.brain.conversations.findById(conversationId, userId); if (!existing) { await this.brain.conversations.create({ id: conversationId, userId, }); } } catch (err) { this.logger.error( `Failed to ensure conversation record for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), ); } } /** * Load prior conversation messages from DB for context injection on session resume (M1-004). * Returns an empty array when no history exists, the conversation is not owned by the user, * or userId is not provided. */ private async loadConversationHistory( conversationId: string, userId: string | undefined, ): Promise { if (!userId) return []; try { const messages = await this.brain.conversations.findMessages(conversationId, userId); if (messages.length === 0) return []; return messages.map((msg) => { const attachments = this.persistedChannelAttachments(msg.metadata); return { role: msg.role as 'user' | 'assistant' | 'system', content: msg.content, createdAt: msg.createdAt, ...(attachments ? { attachments } : {}), }; }); } catch (err) { this.logger.error( `Failed to load conversation history for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), ); return []; } } private persistedChannelAttachments(metadata: unknown): readonly ChannelAttachmentDto[] | null { if (typeof metadata !== 'object' || metadata === null) return null; const attachments = (metadata as { channelAttachments?: unknown }).channelAttachments; return hasValidAttachmentArray(attachments, isChannelAttachment) ? (attachments as readonly ChannelAttachmentDto[]) : null; } private appendAndFlushRedactedEgress( client: Socket, conversationId: string, eventName: 'agent:text' | 'agent:thinking', buffers: Map, delta: string, ): void { const sessionKey = this.clientConversationKey(client, conversationId); const key = this.egressKey(client, conversationId, eventName); if (this.overflowedEgress.has(key)) return; const buffered = `${buffers.get(sessionKey) ?? ''}${delta}`; if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) { buffers.delete(sessionKey); this.overflowedEgress.add(key); client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' }); return; } buffers.set(sessionKey, buffered); this.flushRedactedEgress(client, conversationId, eventName, buffers, false); } /** * Holds any suffix that could become a secret, email, or phone number after a * later stream chunk. This avoids relying on downstream redaction after data * has already reached the socket. */ private flushRedactedEgress( client: Socket, conversationId: string, eventName: 'agent:text' | 'agent:thinking', buffers: Map, final: boolean, ): void { const sessionKey = this.clientConversationKey(client, conversationId); const key = this.egressKey(client, conversationId, eventName); if (this.overflowedEgress.has(key)) { if (final) this.overflowedEgress.delete(key); return; } const buffered = buffers.get(sessionKey) ?? ''; const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered); const released = buffered.slice(0, releaseLength); const pending = buffered.slice(releaseLength); if (pending) { buffers.set(sessionKey, pending); } else { buffers.delete(sessionKey); } if (released) { client.emit(eventName, { conversationId, text: redactSensitiveContent(released).content, }); } } private safeRedactionPrefixLength(content: string): number { let retainedFrom = content.length; // Retain the current token because it may become a split secret or email. const token = /(?:^|\s)(\S*)$/.exec(content); if (token) { const matched = token[0] ?? ''; const trailingToken = token[1] ?? ''; retainedFrom = token.index + matched.length - trailingToken.length; } // The secret classifier accepts whitespace around ':' and '=', so preserve // a pending label until its value and delimiter are both complete. const pendingSecretLabel = /(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec( content, ); if (pendingSecretLabel) { const label = pendingSecretLabel[1] ?? ''; retainedFrom = Math.min( retainedFrom, pendingSecretLabel.index + pendingSecretLabel[0].length - label.length, ); } const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec( content, ); if (secretLabel) { retainedFrom = Math.min(retainedFrom, secretLabel.index); } // Phone numbers can contain whitespace and punctuation; preserve the full // trailing numeric candidate until a non-phone character establishes a boundary. const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content); if (phone) { const matched = phone[0] ?? ''; const trailingPhoneCandidate = phone[1] ?? ''; retainedFrom = Math.min( retainedFrom, phone.index + matched.length - trailingPhoneCandidate.length, ); } const privateKeyStart = content.lastIndexOf('-----BEGIN'); if (privateKeyStart >= 0) { const privateKey = content.slice(privateKeyStart); if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) { // Release the complete block in one pass so the full-block classifier can redact it. retainedFrom = content.length; } else { retainedFrom = Math.min(retainedFrom, privateKeyStart); } } return retainedFrom; } private egressKey( client: Socket, conversationId: string, eventName: 'agent:text' | 'agent:thinking', ): string { return `${this.clientConversationKey(client, conversationId)}:${eventName}`; } /** * Relay one normalized {@link LegacyRuntimeEvent} to the socket, preserving the exact legacy * egress event names and shapes. The runtime owns session/token bookkeeping — the gateway never * reads a pi session or records usage here; usage arrives verbatim on the `settled` event. */ private relayEvent(client: Socket, conversationId: string, event: LegacyRuntimeEvent): void { if (!client.connected) { this.logger.warn( `Dropping event ${event.type} for disconnected client=${client.id}, conversation=${conversationId}`, ); return; } const sessionKey = this.clientConversationKey(client, conversationId); switch (event.type) { case 'started': { // Reset accumulation buffers for the new turn const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText = ''; cs.toolCalls = []; cs.pendingToolCalls.clear(); } this.textEgressBuffers.set(sessionKey, ''); this.thinkingEgressBuffers.set(sessionKey, ''); this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:text')); this.overflowedEgress.delete(this.egressKey(client, conversationId, 'agent:thinking')); client.emit('agent:start', { conversationId }); break; } case 'text_delta': { // Keep raw stream material in memory only; persist and emit only redacted text. const cs = this.clientSessions.get(sessionKey); if (cs) { cs.assistantText += event.text; } this.appendAndFlushRedactedEgress( client, conversationId, 'agent:text', this.textEgressBuffers, event.text, ); break; } case 'thinking_delta': { this.appendAndFlushRedactedEgress( client, conversationId, 'agent:thinking', this.thinkingEgressBuffers, event.text, ); break; } case 'tool_started': { // Track pending tool call for later recording const cs = this.clientSessions.get(sessionKey); if (cs) { cs.pendingToolCalls.set(event.toolCallId, { toolName: event.toolName, args: undefined, }); } client.emit('agent:tool:start', { conversationId, toolCallId: event.toolCallId, toolName: event.toolName, }); break; } case 'tool_finished': { // Finalise tool call record const cs = this.clientSessions.get(sessionKey); if (cs) { const pending = cs.pendingToolCalls.get(event.toolCallId); cs.toolCalls.push({ toolCallId: event.toolCallId, toolName: event.toolName, args: pending?.args ?? null, isError: event.isError, }); cs.pendingToolCalls.delete(event.toolCallId); } client.emit('agent:tool:end', { conversationId, toolCallId: event.toolCallId, toolName: event.toolName, isError: event.isError, }); break; } case 'settled': { this.flushRedactedEgress( client, conversationId, 'agent:text', this.textEgressBuffers, true, ); this.flushRedactedEgress( client, conversationId, 'agent:thinking', this.thinkingEgressBuffers, true, ); client.emit('agent:end', { conversationId, usage: event.usage }); // Persist the assistant message with metadata const cs = this.clientSessions.get(sessionKey); const userId = (client.data.user as { id: string } | undefined)?.id; if (cs && userId && cs.assistantText.trim().length > 0) { const metadata: Record = { timestamp: new Date().toISOString(), model: event.usage?.modelId ?? 'unknown', provider: event.usage?.provider ?? 'unknown', toolCalls: cs.toolCalls, ...(event.usage?.tokens ? { tokenUsage: event.usage.tokens } : {}), }; this.brain.conversations .addMessage( { conversationId, role: 'assistant', content: redactSensitiveContent(cs.assistantText).content, metadata: { ...metadata, classifications: redactSensitiveContent(cs.assistantText).classifications, }, }, userId, ) .catch((err: unknown) => { this.logger.error( `Failed to persist assistant message for conversation=${conversationId}`, err instanceof Error ? err.stack : String(err), ); }); // Reset accumulation cs.assistantText = ''; cs.toolCalls = []; cs.pendingToolCalls.clear(); } break; } } } }