This commit was merged in pull request #730.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
@@ -13,6 +14,9 @@ import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import {
|
||||
verifyDiscordIngressEnvelope,
|
||||
parseDiscordInteractionBindings,
|
||||
resolveDiscordInteractionActorId,
|
||||
resolveDiscordInteractionBinding,
|
||||
type DiscordIngressEnvelope,
|
||||
type DiscordIngressPayload,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
@@ -28,6 +32,7 @@ import type {
|
||||
AbortPayload,
|
||||
} from '@mosaicstack/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import {
|
||||
scopeFromUser,
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
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 { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
@@ -122,6 +128,12 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
||||
@Optional()
|
||||
@Inject(RuntimeProviderService)
|
||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -637,9 +649,130 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* 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<void> {
|
||||
if (!client.data.discordService) return;
|
||||
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
||||
const actionParts = ingress?.content.match(/^\/approve\s+([^\s]+)\s+([^\s]+)$/i);
|
||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||
if (!ingress || !actionParts || !tenantId || !this.commandAuthorization) return;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
ingress.guildId,
|
||||
ingress.channelId,
|
||||
ingress.userId,
|
||||
'approve',
|
||||
);
|
||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!actorId || !agentName || binding.instanceId !== 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;
|
||||
}
|
||||
const providerId = actionParts[1]!;
|
||||
const sessionId = actionParts[2]!;
|
||||
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
||||
providerId,
|
||||
sessionId,
|
||||
actorId,
|
||||
tenantId,
|
||||
channelId: ingress.channelId,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
providerId,
|
||||
sessionId,
|
||||
),
|
||||
agentName,
|
||||
});
|
||||
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<void> {
|
||||
if (!client.data.discordService) return;
|
||||
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
||||
const actionParts = ingress?.content.match(/^\/stop\s+([^\s]+)\s+([^\s]+)\s+([^\s]+)$/i);
|
||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||
if (!ingress || !actionParts || !tenantId || !this.runtimeRegistry) return;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
ingress.guildId,
|
||||
ingress.channelId,
|
||||
ingress.userId,
|
||||
'stop',
|
||||
);
|
||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||
if (!actorId) return;
|
||||
const providerId = actionParts[1]!;
|
||||
const sessionId = actionParts[2]!;
|
||||
|
||||
try {
|
||||
// RuntimeProviderService consumes the durable approval exactly once using the
|
||||
// provisioned approving-admin identity, never the Discord service account.
|
||||
await this.runtimeRegistry.terminate(providerId, sessionId, actionParts[3]!, {
|
||||
actorScope: {
|
||||
userId: actorId,
|
||||
tenantId,
|
||||
},
|
||||
channelId: ingress.channelId,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
providerId,
|
||||
sessionId,
|
||||
),
|
||||
});
|
||||
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',
|
||||
): DiscordIngressPayload | null {
|
||||
const payload = verifyDiscordIngressEnvelope(
|
||||
envelope,
|
||||
@@ -654,6 +787,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
payload.guildId,
|
||||
payload.channelId,
|
||||
payload.userId,
|
||||
operation,
|
||||
);
|
||||
if (!binding) {
|
||||
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||
this.logger.warn(
|
||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||
|
||||
Reference in New Issue
Block a user