Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
1023 lines
37 KiB
TypeScript
1023 lines
37 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createDiscordIngressEnvelope,
|
|
verifyDiscordIngressEnvelope,
|
|
DiscordPlugin,
|
|
type DiscordIngressPayload,
|
|
parseDiscordInteractionBindings,
|
|
resolveDiscordInteractionActorId,
|
|
resolveDiscordInteractionBinding,
|
|
} from '@mosaicstack/discord-plugin';
|
|
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
|
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
|
import { ChatRuntimeRouter } from '../chat/chat-runtime-router.js';
|
|
import { EmbeddedChatRuntime } from '../chat/embedded-chat.runtime.js';
|
|
import { HarnessChatRuntime } from '../chat/harness-chat.runtime.js';
|
|
import { HarnessRegistry } from '../harness/harness.registry.js';
|
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
|
|
|
const SERVICE_TOKEN = 'test-service-token';
|
|
const ENV_KEYS = [
|
|
'DISCORD_SERVICE_TOKEN',
|
|
'DISCORD_SERVICE_USER_ID',
|
|
'DISCORD_SERVICE_TENANT_ID',
|
|
'DISCORD_INTERACTION_BINDINGS',
|
|
'DISCORD_ALLOWED_GUILD_IDS',
|
|
'DISCORD_ALLOWED_CHANNEL_IDS',
|
|
'DISCORD_ALLOWED_USER_IDS',
|
|
'MOSAIC_AGENT_NAME',
|
|
'MOSAIC_AGENT_CONFIG_ID',
|
|
'CHAT_HARNESS_RUNTIME',
|
|
] as const;
|
|
const savedEnv = new Map<string, string | undefined>();
|
|
|
|
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
|
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
|
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
|
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: {
|
|
'user-001': {
|
|
role: role === 'admin' ? 'admin' : 'operator',
|
|
mosaicUserId: 'mosaic-admin-001',
|
|
},
|
|
},
|
|
},
|
|
]);
|
|
}
|
|
|
|
afterEach((): void => {
|
|
for (const key of ENV_KEYS) {
|
|
const value = savedEnv.get(key);
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
savedEnv.clear();
|
|
});
|
|
|
|
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
|
const entries = new Map<string, string>();
|
|
const db = {
|
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
|
};
|
|
const redis = {
|
|
get: async (key: string) => entries.get(key) ?? null,
|
|
set: async (key: string, value: string) => entries.set(key, value),
|
|
del: async (key: string) => Number(entries.delete(key)),
|
|
};
|
|
return new CommandAuthorizationService(db as never, redis);
|
|
}
|
|
|
|
function discordGateway(role: 'admin' | 'member'): {
|
|
gateway: ChatGateway;
|
|
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
|
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
|
durable: { getSnapshot: ReturnType<typeof vi.fn> };
|
|
audit: { record: ReturnType<typeof vi.fn> };
|
|
} {
|
|
const authorization = commandAuthorization(role);
|
|
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
|
const durable = {
|
|
getSnapshot: vi.fn().mockResolvedValue({
|
|
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
|
}),
|
|
};
|
|
const audit = { record: vi.fn().mockResolvedValue(undefined) };
|
|
const runtimeRegistry = new RuntimeProviderService(
|
|
{
|
|
require: () => ({
|
|
capabilities: async () => ({ supported: ['session.terminate'] }),
|
|
terminate: async () => undefined,
|
|
}),
|
|
} as never,
|
|
{ record: async () => undefined } as never,
|
|
{
|
|
consume: async (approvalId, action) => {
|
|
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
|
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
|
},
|
|
},
|
|
);
|
|
return {
|
|
gateway: new ChatGateway(
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
{} as never,
|
|
authorization,
|
|
runtimeRegistry,
|
|
durable as never,
|
|
audit as never,
|
|
),
|
|
client: { data: { discordService: true }, emit: vi.fn() },
|
|
consumedActions,
|
|
durable,
|
|
audit,
|
|
};
|
|
}
|
|
|
|
function ingressEnvelope(
|
|
content: string,
|
|
messageId: string,
|
|
overrides: Partial<DiscordIngressPayload> = {},
|
|
): ReturnType<typeof createDiscordIngressEnvelope> {
|
|
return createDiscordIngressEnvelope(
|
|
createPayload({ content, messageId, ...overrides }),
|
|
SERVICE_TOKEN,
|
|
);
|
|
}
|
|
|
|
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
|
return {
|
|
correlationId: 'correlation-001',
|
|
messageId: 'discord-message-001',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
userId: 'user-001',
|
|
conversationId: 'Nova:discord:channel-001',
|
|
content: 'hello Tess',
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Task 5 fence (C): the Discord SEND path runs through the exclusive {@link ChatRuntimeRouter},
|
|
* constructed here in `pi-rpc` mode with a fully-resolved runtime (`active` = harness). A verified
|
|
* Discord *service* turn must nonetheless execute on the {@link EmbeddedChatRuntime} — never the
|
|
* harness, never the routing engine — per the Q1/Q2 adjudication: the router owns a dedicated
|
|
* verified-ingress dispatch that delegates to embedded regardless of mode, with zero harness
|
|
* fallback. The gateway is given the router in the former direct-`AgentService` constructor slot.
|
|
*
|
|
* RED today: production still reads that slot as a bare `AgentService`, so `this.agentService`
|
|
* resolves to the router, `getSession(...)` is not a function, the send path throws and is caught
|
|
* (an `error` is emitted and the handler returns) BEFORE it ever reaches the embedded runtime. The
|
|
* failure is behavioural wiring — collection, DI, and `onModuleInit` all succeed. GREEN re-routes
|
|
* the verified Discord dispatch through the router into the embedded runtime, satisfying the
|
|
* preserved create/prompt assertions without weakening any control. `harnessConversations.append`
|
|
* proves the harness path is never touched even though the pi-rpc router resolved it as `active`.
|
|
*
|
|
* Correction #4 is proved behaviourally, not by naming an accessor: the verified-ingress dispatch
|
|
* is reachable only from the fully-verified `discordService` branch (the create/prompt tests below)
|
|
* and never from a browser-emittable socket event (the browser-forgery refusal test).
|
|
*/
|
|
function readyPiRpcRegistry(): HarnessRegistry {
|
|
const registry = new HarnessRegistry();
|
|
// A registered 'pi' adapter + an available (non-sentinel) conversation service let the pi-rpc
|
|
// router resolve `active` = harness instead of failing closed at init, so these tests model the
|
|
// real hostile condition — the harness runtime IS live — rather than a degraded router.
|
|
registry.register({ id: 'pi' } as never);
|
|
return registry;
|
|
}
|
|
|
|
function piRpcRouterFronting(
|
|
agentService: unknown,
|
|
harnessConversations: { append: ReturnType<typeof vi.fn> },
|
|
): ChatRuntimeRouter {
|
|
const routerConversationServiceTripwire = {
|
|
append: () => {
|
|
throw new Error('router conversation service must not be resolved on the Discord path');
|
|
},
|
|
};
|
|
const embedded = new EmbeddedChatRuntime(agentService as never);
|
|
const harness = new HarnessChatRuntime(harnessConversations as never);
|
|
const router = new ChatRuntimeRouter(
|
|
readyPiRpcRegistry(),
|
|
routerConversationServiceTripwire as never,
|
|
embedded,
|
|
harness,
|
|
'pi-rpc',
|
|
);
|
|
router.onModuleInit();
|
|
return router;
|
|
}
|
|
|
|
describe('Discord ingress security', () => {
|
|
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
|
const [binding] = parseDiscordInteractionBindings(
|
|
JSON.stringify([
|
|
{
|
|
instanceId: 'Nova',
|
|
agentConfigId: 'agent-config-nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: { 'user-001': 'admin' },
|
|
},
|
|
]),
|
|
);
|
|
expect(
|
|
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
|
).toEqual(binding);
|
|
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
|
});
|
|
|
|
it('binds a differently named configured interaction instance without code changes', () => {
|
|
const binding = resolveDiscordInteractionBinding(
|
|
[
|
|
{
|
|
instanceId: 'Nova',
|
|
agentConfigId: 'agent-config-nova',
|
|
guildId: 'guild-001',
|
|
channelId: 'channel-001',
|
|
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
|
},
|
|
],
|
|
'guild-001',
|
|
'channel-001',
|
|
'user-001',
|
|
'send',
|
|
);
|
|
|
|
expect(binding?.instanceId).toBe('Nova');
|
|
});
|
|
|
|
it('accepts only the configured Discord service identity', () => {
|
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
|
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
|
|
});
|
|
|
|
it('rejects unauthenticated or tampered service envelopes', () => {
|
|
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
|
|
|
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
|
|
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
|
|
expect(
|
|
verifyDiscordIngressEnvelope(
|
|
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
|
|
SERVICE_TOKEN,
|
|
),
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each([
|
|
['guild', { guildId: 'unlisted-guild' }],
|
|
['channel', { channelId: 'unlisted-channel' }],
|
|
['user', { userId: 'unlisted-user' }],
|
|
])(
|
|
'rejects an unallowlisted Discord %s',
|
|
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
|
|
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
|
|
|
|
expect(
|
|
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
guildIds: ['guild-001'],
|
|
channelIds: ['channel-001'],
|
|
userIds: ['user-001'],
|
|
}),
|
|
).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
|
|
const payload = createPayload({
|
|
correlationId: 'correlation-trace-123',
|
|
messageId: 'discord-snowflake-987',
|
|
});
|
|
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
|
|
|
expect(
|
|
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
|
guildIds: ['guild-001'],
|
|
channelIds: ['channel-001'],
|
|
userIds: ['user-001'],
|
|
}),
|
|
).toEqual(payload);
|
|
});
|
|
|
|
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
|
|
const replayProtector = new DiscordReplayProtector(60_000, 2);
|
|
|
|
expect(replayProtector.claim('discord-message-001')).toBe(true);
|
|
expect(replayProtector.claim('discord-message-001')).toBe(false);
|
|
expect(replayProtector.claim('discord-message-002')).toBe(true);
|
|
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
|
expect(replayProtector.size).toBe(2);
|
|
});
|
|
|
|
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
|
configureDiscordEnv();
|
|
const { gateway, client, consumedActions } = discordGateway('admin');
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve', 'approve-message', {
|
|
correlationId: 'approval-ingress-correlation',
|
|
}),
|
|
);
|
|
const approval = client.emit.mock.calls.find(
|
|
([event]) => event === 'discord:approval',
|
|
)?.[1] as {
|
|
approvalId: string;
|
|
success: boolean;
|
|
};
|
|
expect(approval.success).toBe(true);
|
|
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
|
|
correlationId: 'stop-ingress-correlation',
|
|
}),
|
|
);
|
|
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
|
correlationId: 'stop-ingress-correlation',
|
|
success: true,
|
|
});
|
|
expect(consumedActions).toEqual([
|
|
{
|
|
actorId: 'mosaic-admin-001',
|
|
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('audits a Discord mint-side authorization denial', async () => {
|
|
configureDiscordEnv();
|
|
const { gateway, client, audit } = discordGateway('member');
|
|
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve', 'denied-approve'),
|
|
);
|
|
|
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
correlationId: 'correlation-001',
|
|
success: false,
|
|
approvalId: undefined,
|
|
expiresAt: undefined,
|
|
});
|
|
expect(audit.record).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
outcome: 'denied',
|
|
operation: 'session.terminate',
|
|
errorCode: 'policy_denied',
|
|
}),
|
|
);
|
|
});
|
|
|
|
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'),
|
|
);
|
|
|
|
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();
|
|
const { gateway, client } = discordGateway('member');
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve', 'member-approve'),
|
|
);
|
|
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
|
correlationId: 'correlation-001',
|
|
success: false,
|
|
approvalId: undefined,
|
|
expiresAt: undefined,
|
|
});
|
|
|
|
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope('/stop forged', 'unpaired-stop'),
|
|
);
|
|
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
|
});
|
|
|
|
it('rejects replaying a Discord-created termination approval', async () => {
|
|
configureDiscordEnv();
|
|
const { gateway, client } = discordGateway('admin');
|
|
await gateway.handleDiscordApproval(
|
|
client as never,
|
|
ingressEnvelope('/approve', 'replay-approve', {
|
|
correlationId: 'replay-approval-correlation',
|
|
}),
|
|
);
|
|
const approval = client.emit.mock.calls.find(
|
|
([event]) => event === 'discord:approval',
|
|
)?.[1] as {
|
|
approvalId: string;
|
|
};
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
|
|
correlationId: 'replay-stop-correlation-one',
|
|
}),
|
|
);
|
|
await gateway.handleDiscordStop(
|
|
client as never,
|
|
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
|
|
correlationId: 'replay-stop-correlation-two',
|
|
}),
|
|
);
|
|
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
|
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
|
true,
|
|
false,
|
|
]);
|
|
});
|
|
|
|
it.each([
|
|
'https://user:[email protected]/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['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
|
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 harnessConversations = { append: vi.fn() };
|
|
const gateway = new ChatGateway(
|
|
piRpcRouterFronting(agentService, harnessConversations) 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();
|
|
// Even though the pi-rpc router resolved the harness as `active`, verified Discord ingress must
|
|
// never touch it — the create path stays on the embedded runtime.
|
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dispatches a verified Discord SEND once and drops a byte-identical replay with zero additional dispatch/persist/ack (Task 5 G4)', async () => {
|
|
configureDiscordEnv();
|
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
|
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' },
|
|
},
|
|
},
|
|
]);
|
|
const session = {
|
|
provider: 'configured-provider',
|
|
modelId: 'configured-model',
|
|
piSession: {
|
|
thinkingLevel: 'medium',
|
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
|
},
|
|
};
|
|
const createSession = vi.fn().mockResolvedValue(session);
|
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
|
const agentService = {
|
|
getSession: vi.fn().mockReturnValue(undefined),
|
|
createSession,
|
|
recordMessage: vi.fn(),
|
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
|
addChannel: vi.fn(),
|
|
removeChannel: vi.fn(),
|
|
prompt,
|
|
};
|
|
const addMessage = vi.fn().mockResolvedValue(undefined);
|
|
const brain = {
|
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: '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,
|
|
},
|
|
};
|
|
const harnessConversations = { append: vi.fn() };
|
|
const gateway = new ChatGateway(
|
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
|
{} as never,
|
|
brain as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ resolve: vi.fn() } as never,
|
|
);
|
|
const client = {
|
|
id: 'discord-client-replay',
|
|
data: { discordService: true },
|
|
emit: vi.fn(),
|
|
};
|
|
const ackCount = (): number =>
|
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
|
|
|
// One fully-valid signed envelope; the replay reuses the SAME object (same messageId).
|
|
const envelope = ingressEnvelope('verified once', 'discord-replay-001', {
|
|
conversationId: 'Nova:discord:channel-001',
|
|
});
|
|
|
|
// First delivery: the verified-Discord SEND runs the full embedded dispatch exactly once.
|
|
await gateway.handleMessage(client as never, envelope);
|
|
expect(createSession).toHaveBeenCalledTimes(1);
|
|
expect(prompt).toHaveBeenCalledTimes(1);
|
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
|
expect(ackCount()).toBe(1);
|
|
|
|
// Byte-identical replay: the messageId is already claimed, so resolveDiscordIngress returns
|
|
// null and the SEND handler bails before dispatch/persist/ack. Every effect stays at exactly one.
|
|
await gateway.handleMessage(client as never, envelope);
|
|
expect(createSession).toHaveBeenCalledTimes(1);
|
|
expect(prompt).toHaveBeenCalledTimes(1);
|
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
|
expect(ackCount()).toBe(1);
|
|
// The harness runtime is never touched on either delivery.
|
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('a verified SEND that fails the configured service identity consumes no replay claim, so a corrected byte-identical retry dispatches/persists/acks exactly once and a later duplicate stays fail-closed (Task 5 item 4 — claim ordering)', async () => {
|
|
configureDiscordEnv();
|
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
|
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
|
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' },
|
|
},
|
|
},
|
|
]);
|
|
const session = {
|
|
provider: 'configured-provider',
|
|
modelId: 'configured-model',
|
|
piSession: {
|
|
thinkingLevel: 'medium',
|
|
getAvailableThinkingLevels: (): string[] => ['medium'],
|
|
},
|
|
};
|
|
const createSession = vi.fn().mockResolvedValue(session);
|
|
const prompt = vi.fn().mockResolvedValue(undefined);
|
|
const agentService = {
|
|
getSession: vi.fn().mockReturnValue(undefined),
|
|
createSession,
|
|
recordMessage: vi.fn(),
|
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
|
addChannel: vi.fn(),
|
|
removeChannel: vi.fn(),
|
|
prompt,
|
|
};
|
|
const addMessage = vi.fn().mockResolvedValue(undefined);
|
|
const brain = {
|
|
agents: { findById: vi.fn((id: string) => Promise.resolve({ id, name: '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,
|
|
},
|
|
};
|
|
const harnessConversations = { append: vi.fn() };
|
|
const gateway = new ChatGateway(
|
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
|
{} as never,
|
|
brain as never,
|
|
{} as never,
|
|
{} as never,
|
|
{ resolve: vi.fn() } as never,
|
|
);
|
|
const client = {
|
|
id: 'discord-client-claim-ordering',
|
|
data: { discordService: true },
|
|
emit: vi.fn(),
|
|
};
|
|
const ackCount = (): number =>
|
|
client.emit.mock.calls.filter((call) => call[0] === 'message:ack').length;
|
|
|
|
// A single fully-valid signed envelope, reused byte-for-byte across all three deliveries.
|
|
const envelope = ingressEnvelope('verified once with late identity', 'discord-order-001', {
|
|
conversationId: 'Nova:discord:channel-001',
|
|
});
|
|
|
|
// (1) Configured service identity is MISSING. The envelope is validly signed and passes the
|
|
// binding + route checks, but the SEND must refuse at the identity gate BEFORE any claim
|
|
// or effect. If the claim fires ahead of that gate, this delivery silently burns the
|
|
// replay claim for `discord-order-001` even though nothing dispatched.
|
|
delete process.env['DISCORD_SERVICE_USER_ID'];
|
|
await gateway.handleMessage(client as never, envelope);
|
|
expect(createSession).toHaveBeenCalledTimes(0);
|
|
expect(prompt).toHaveBeenCalledTimes(0);
|
|
expect(addMessage).toHaveBeenCalledTimes(0);
|
|
expect(ackCount()).toBe(0);
|
|
|
|
// (2) Identity is now configured; the operator resends the SAME envelope byte-for-byte. Because
|
|
// step (1) consumed no claim, this corrected retry claims once and runs the full embedded
|
|
// dispatch exactly once. (Under the pre-fix ordering the claim was already spent in step (1),
|
|
// so this retry is dropped as a replay and never dispatches — the RED this test drives.)
|
|
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
|
await gateway.handleMessage(client as never, envelope);
|
|
expect(createSession).toHaveBeenCalledTimes(1);
|
|
expect(prompt).toHaveBeenCalledTimes(1);
|
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
|
expect(ackCount()).toBe(1);
|
|
|
|
// (3) A genuine duplicate after a committed turn stays fail-closed: the claim taken in step (2)
|
|
// blocks it, so every effect remains at exactly one.
|
|
await gateway.handleMessage(client as never, envelope);
|
|
expect(createSession).toHaveBeenCalledTimes(1);
|
|
expect(prompt).toHaveBeenCalledTimes(1);
|
|
expect(addMessage).toHaveBeenCalledTimes(1);
|
|
expect(ackCount()).toBe(1);
|
|
expect(harnessConversations.append).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();
|
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
|
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 harnessConversations = { append: vi.fn() };
|
|
const gateway = new ChatGateway(
|
|
piRpcRouterFronting(agentService, harnessConversations) 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',
|
|
);
|
|
// The verified Discord prompt dispatch stays on the embedded runtime; the pi-rpc harness that
|
|
// the router resolved as `active` is never reached.
|
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses a browser-forged Discord ingress envelope in pi-rpc with a fixed typed refusal and zero dispatch', async () => {
|
|
// Correction #2 + #4 (behavioural). A browser socket is never `discordService` (that flag is
|
|
// set only on a valid service-token handshake), so it cannot forge the trusted Discord path by
|
|
// emitting an envelope-shaped payload. In pi-rpc it must receive a FIXED TYPED refusal
|
|
// (`runtime_unsupported`, the same typed code the sibling harness-fence uses) and reach neither
|
|
// the forced Discord service scope, the verified Discord operation, the embedded runtime, nor
|
|
// the harness. There is no dedicated socket event for verified ingress — the only ingress
|
|
// surface is the generic `message` handler, and a non-service client is refused there.
|
|
//
|
|
// RED today: a non-service client emitting an envelope-shaped payload falls to the browser
|
|
// branch, fails the chat-message shape check, and is dropped SILENTLY (a warn + return) with no
|
|
// typed refusal emitted — so the refusal assertion fails. Collection and construction succeed;
|
|
// the gap is behavioural. GREEN emits the fixed typed refusal before any dispatch.
|
|
configureDiscordEnv();
|
|
process.env['CHAT_HARNESS_RUNTIME'] = 'pi-rpc';
|
|
const agentService = {
|
|
getSession: vi.fn().mockReturnValue(undefined),
|
|
createSession: vi.fn(),
|
|
recordMessage: vi.fn(),
|
|
onEvent: vi.fn().mockReturnValue((): void => undefined),
|
|
addChannel: vi.fn(),
|
|
prompt: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
const harnessConversations = { append: vi.fn() };
|
|
const routingEngine = { resolve: vi.fn() };
|
|
const gateway = new ChatGateway(
|
|
piRpcRouterFronting(agentService, harnessConversations) as never,
|
|
{} as never,
|
|
{ conversations: { addMessage: vi.fn().mockResolvedValue(undefined) } } as never,
|
|
{} as never,
|
|
{} as never,
|
|
routingEngine as never,
|
|
);
|
|
const client = {
|
|
id: 'browser-forging-discord',
|
|
data: { discordService: false },
|
|
emit: vi.fn(),
|
|
};
|
|
|
|
await gateway.handleMessage(
|
|
client as never,
|
|
ingressEnvelope('forged from a browser', 'browser-forgery-001', {
|
|
conversationId: 'Nova:discord:channel-001',
|
|
}),
|
|
);
|
|
|
|
const refusal = client.emit.mock.calls.find(
|
|
([, payload]) => (payload as { code?: string } | undefined)?.code === 'runtime_unsupported',
|
|
);
|
|
expect(refusal).toBeDefined();
|
|
expect(client.emit).not.toHaveBeenCalledWith('message:ack', expect.anything());
|
|
expect(agentService.createSession).not.toHaveBeenCalled();
|
|
expect(agentService.prompt).not.toHaveBeenCalled();
|
|
expect(harnessConversations.append).not.toHaveBeenCalled();
|
|
expect(routingEngine.resolve).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts a thread message through its allowed bound parent channel', () => {
|
|
const emitted = 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: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
|
},
|
|
],
|
|
});
|
|
const internals = plugin as unknown as {
|
|
client: { user: { id: string } };
|
|
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
|
handleDiscordMessage(message: unknown): void;
|
|
};
|
|
internals.client = { user: { id: 'bot-001' } };
|
|
internals.socket = { connected: true, emit: emitted };
|
|
internals.handleDiscordMessage({
|
|
id: 'thread-message',
|
|
guildId: 'guild-001',
|
|
channelId: 'thread-001',
|
|
author: { id: 'user-001', bot: false },
|
|
mentions: { has: () => true },
|
|
content: '<@bot-001> hello from thread',
|
|
channel: { parentId: 'channel-001' },
|
|
attachments: new Map(),
|
|
});
|
|
|
|
const [, envelope] = emitted.mock.calls[0] as [
|
|
string,
|
|
ReturnType<typeof createDiscordIngressEnvelope>,
|
|
];
|
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
|
});
|
|
});
|