@@ -24,6 +24,7 @@ const ENV_KEYS = [
|
||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||
'DISCORD_ALLOWED_USER_IDS',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
'MOSAIC_AGENT_CONFIG_ID',
|
||||
] as const;
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
|
||||
@@ -33,12 +34,14 @@ function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
||||
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
process.env['MOSAIC_AGENT_CONFIG_ID'] = 'agent-config-nova';
|
||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
@@ -141,7 +144,7 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
userId: 'user-001',
|
||||
conversationId: 'discord-channel-001',
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
content: 'hello Tess',
|
||||
...overrides,
|
||||
};
|
||||
@@ -153,6 +156,7 @@ describe('Discord ingress security', () => {
|
||||
JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': 'admin' },
|
||||
@@ -170,6 +174,7 @@ describe('Discord ingress security', () => {
|
||||
[
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||
@@ -307,41 +312,46 @@ describe('Discord ingress security', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'binding',
|
||||
() => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
||||
},
|
||||
],
|
||||
[
|
||||
'durable session',
|
||||
(durable: { getSnapshot: ReturnType<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);
|
||||
it('rejects approval when the durable session targets a different logical agent', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client, durable } = discordGateway('admin');
|
||||
durable.getSnapshot.mockResolvedValueOnce({
|
||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
});
|
||||
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
||||
);
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
||||
);
|
||||
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||
correlationId: 'correlation-001',
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||
correlationId: 'correlation-001',
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects privileged envelopes with a forged current conversation route', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client } = discordGateway('admin');
|
||||
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'forged-approval-route', {
|
||||
conversationId: 'Nova:discord:other-channel',
|
||||
}),
|
||||
);
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope('/stop forged', 'forged-stop-route', {
|
||||
conversationId: 'Nova:discord:other-channel',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.emit).not.toHaveBeenCalledWith('discord:approval', expect.anything());
|
||||
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
||||
});
|
||||
|
||||
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||
configureDiscordEnv();
|
||||
@@ -398,6 +408,267 @@ describe('Discord ingress security', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://user:password@cdn.example.test/diagram.png',
|
||||
'https://cdn.example.test/diagram.png?token=secret',
|
||||
'https://cdn.example.test/diagram.png?X-Amz-Signature=secret',
|
||||
'https://cdn.example.test/diagram.png?auth=secret',
|
||||
'https://cdn.example.test/diagram.png?hm=secret',
|
||||
])('rejects credential-bearing attachment URLs before gateway dispatch', async (url) => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client } = discordGateway('admin');
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
ingressEnvelope('', `credential-url-${url.length}`, {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
attachments: [
|
||||
{ id: 'attachment-credential', name: 'diagram.png', url, contentType: 'image/png' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
||||
});
|
||||
|
||||
it("selects each binding's trusted logical-agent config when creating Discord sessions", async () => {
|
||||
configureDiscordEnv();
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001,channel-002';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
{
|
||||
instanceId: 'Orion',
|
||||
agentConfigId: 'agent-config-orion',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-002',
|
||||
pairedUsers: {
|
||||
'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
const session = {
|
||||
provider: 'configured-provider',
|
||||
modelId: 'configured-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'medium',
|
||||
getAvailableThinkingLevels: (): string[] => ['medium'],
|
||||
},
|
||||
};
|
||||
const createSession = vi.fn().mockResolvedValue(session);
|
||||
const agentService = {
|
||||
getSession: vi.fn().mockReturnValue(undefined),
|
||||
createSession,
|
||||
recordMessage: vi.fn(),
|
||||
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
||||
addChannel: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const brain = {
|
||||
agents: {
|
||||
findById: vi.fn((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
name: id === 'agent-config-orion' ? 'Orion' : 'Nova',
|
||||
}),
|
||||
),
|
||||
},
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue({ id: 'Nova:discord:channel-001' }),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
const routingEngine = { resolve: vi.fn() };
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
routingEngine as never,
|
||||
);
|
||||
const client = {
|
||||
id: 'discord-client-new-session',
|
||||
data: { discordService: true },
|
||||
emit: vi.fn(),
|
||||
};
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
ingressEnvelope('start configured session', 'configured-session-001', {
|
||||
conversationId: 'Nova:discord:channel-001',
|
||||
}),
|
||||
);
|
||||
|
||||
await gateway.handleMessage(
|
||||
client as never,
|
||||
ingressEnvelope('start second configured session', 'configured-session-002', {
|
||||
channelId: 'channel-002',
|
||||
conversationId: 'Orion:discord:channel-002',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
'Nova:discord:channel-001',
|
||||
expect.objectContaining({
|
||||
agentConfigId: 'agent-config-nova',
|
||||
userId: 'discord-service',
|
||||
tenantId: 'tenant-discord',
|
||||
}),
|
||||
);
|
||||
expect(createSession).toHaveBeenCalledWith(
|
||||
'Orion:discord:channel-002',
|
||||
expect.objectContaining({ agentConfigId: 'agent-config-orion' }),
|
||||
);
|
||||
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retains validated persisted attachments in resumed conversation history', async () => {
|
||||
const attachment = {
|
||||
id: 'attachment-history',
|
||||
name: 'diagram.png',
|
||||
url: 'https://cdn.example.test/diagram.png',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 4_096,
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
conversations: {
|
||||
findMessages: vi.fn().mockResolvedValue([
|
||||
{
|
||||
role: 'user',
|
||||
content: '',
|
||||
createdAt: new Date('2026-07-14T12:00:00.000Z'),
|
||||
metadata: { channelAttachments: [attachment] },
|
||||
},
|
||||
]),
|
||||
},
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
) as unknown as {
|
||||
loadConversationHistory(
|
||||
conversationId: string,
|
||||
userId: string,
|
||||
): Promise<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', () => {
|
||||
const emitted = vi.fn();
|
||||
const plugin = new DiscordPlugin({
|
||||
@@ -410,6 +681,7 @@ describe('Discord ingress security', () => {
|
||||
interactionBindings: [
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
agentConfigId: 'agent-config-nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||
|
||||
Reference in New Issue
Block a user