Compare commits
3 Commits
fix/tess-d
...
2879da1f6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2879da1f6a | ||
| ca9c2b5c23 | |||
| b580d37d51 |
@@ -5,3 +5,5 @@ pnpm-lock.yaml
|
|||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
.claude/
|
||||||
|
docs/tess/TASKS.md
|
||||||
|
docs/scratchpads/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import type { IncomingHttpHeaders } from 'node:http';
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
|
||||||
@@ -12,6 +13,19 @@ export interface SessionAuth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateDiscordServiceToken(
|
||||||
|
candidate: unknown,
|
||||||
|
expected: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (typeof candidate !== 'string' || !expected) return false;
|
||||||
|
const candidateBuffer = Buffer.from(candidate);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
return (
|
||||||
|
candidateBuffer.length === expectedBuffer.length &&
|
||||||
|
timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateSocketSession(
|
export async function validateSocketSession(
|
||||||
headers: IncomingHttpHeaders,
|
headers: IncomingHttpHeaders,
|
||||||
auth: SessionAuth,
|
auth: SessionAuth,
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import {
|
||||||
|
verifyDiscordIngressEnvelope,
|
||||||
|
type DiscordIngressEnvelope,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type {
|
import type {
|
||||||
@@ -28,7 +33,8 @@ import { CommandExecutorService } from '../commands/command-executor.service.js'
|
|||||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateSocketSession } from './chat.gateway-auth.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. */
|
/** Per-client state tracking streaming accumulation for persistence. */
|
||||||
interface ClientSession {
|
interface ClientSession {
|
||||||
@@ -50,6 +56,37 @@ interface ClientSession {
|
|||||||
*/
|
*/
|
||||||
const modelOverrides = new Map<string, string>();
|
const modelOverrides = new Map<string, string>();
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
return [
|
||||||
|
payload['correlationId'],
|
||||||
|
payload['messageId'],
|
||||||
|
payload['guildId'],
|
||||||
|
payload['channelId'],
|
||||||
|
payload['userId'],
|
||||||
|
payload['conversationId'],
|
||||||
|
payload['content'],
|
||||||
|
].every((field: unknown): boolean => typeof field === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const payload = value as { content?: unknown; conversationId?: unknown };
|
||||||
|
return (
|
||||||
|
typeof payload.content === 'string' &&
|
||||||
|
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||||
@@ -62,6 +99,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
private readonly logger = new Logger(ChatGateway.name);
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
private readonly clientSessions = new Map<string, ClientSession>();
|
private readonly clientSessions = new Map<string, ClientSession>();
|
||||||
|
private readonly discordReplayProtector = new DiscordReplayProtector();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@@ -77,6 +115,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleConnection(client: Socket): Promise<void> {
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
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);
|
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||||
@@ -87,8 +132,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.data.user = session.user;
|
client.data.user = session.user;
|
||||||
client.data.session = session.session;
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
|
||||||
// Broadcast command manifest to the newly connected client
|
|
||||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +148,41 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@SubscribeMessage('message')
|
@SubscribeMessage('message')
|
||||||
async handleMessage(
|
async handleMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: ChatSocketMessageDto,
|
@MessageBody() rawData: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let discordIngress: DiscordIngressPayload | null = null;
|
||||||
|
let data: ChatSocketMessageDto;
|
||||||
|
if (client.data.discordService) {
|
||||||
|
if (!isDiscordIngressEnvelope(rawData)) {
|
||||||
|
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discordIngress = this.resolveDiscordIngress(client, rawData);
|
||||||
|
if (!discordIngress) return;
|
||||||
|
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
|
||||||
|
} else {
|
||||||
|
if (!isChatSocketMessage(rawData)) {
|
||||||
|
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
const conversationId = data.conversationId ?? uuid();
|
const conversationId = data.conversationId ?? uuid();
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
if (discordIngress && !discordServiceUserId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress without configured service owner from ${client.id}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = discordIngress
|
||||||
|
? discordServiceUserId
|
||||||
|
: (client.data.user as { id: string } | undefined)?.id;
|
||||||
|
const correlationId = discordIngress?.correlationId;
|
||||||
|
|
||||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
this.logger.log(
|
||||||
|
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
|
||||||
|
);
|
||||||
|
|
||||||
// Ensure agent session exists for this conversation
|
// Ensure agent session exists for this conversation
|
||||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||||
@@ -213,6 +285,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
content: data.content,
|
content: data.content,
|
||||||
metadata: {
|
metadata: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
...(correlationId
|
||||||
|
? {
|
||||||
|
correlationId,
|
||||||
|
discordMessageId: discordIngress?.messageId,
|
||||||
|
discordUserId: discordIngress?.userId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
@@ -271,7 +350,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
client.emit('message:ack', {
|
||||||
|
conversationId,
|
||||||
|
messageId: uuid(),
|
||||||
|
...(correlationId
|
||||||
|
? {
|
||||||
|
correlationId,
|
||||||
|
discordMessageId: discordIngress?.messageId,
|
||||||
|
discordUserId: discordIngress?.userId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
// Dispatch to agent
|
// Dispatch to agent
|
||||||
try {
|
try {
|
||||||
@@ -442,6 +531,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
* would fail on the PK constraint and be caught here.
|
* would fail on the PK constraint and be caught here.
|
||||||
*/
|
*/
|
||||||
|
private resolveDiscordIngress(
|
||||||
|
client: Socket,
|
||||||
|
envelope: DiscordIngressEnvelope,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDiscordAllowlist(name: string): string[] {
|
||||||
|
return (process.env[name] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((id: string): string => id.trim())
|
||||||
|
.filter((id: string): boolean => id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const existing = await this.brain.conversations.findById(conversationId, userId);
|
const existing = await this.brain.conversations.findById(conversationId, userId);
|
||||||
|
|||||||
89
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
89
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
createDiscordIngressEnvelope,
|
||||||
|
verifyDiscordIngressEnvelope,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
|
const SERVICE_TOKEN = 'test-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: 'discord-channel-001',
|
||||||
|
content: 'hello Tess',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Discord ingress security', () => {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Bounded replay cache for Discord's globally unique native message IDs.
|
||||||
|
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
|
||||||
|
*/
|
||||||
|
export class DiscordReplayProtector {
|
||||||
|
private readonly claimedAt = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ttlMs = 15 * 60 * 1000,
|
||||||
|
private readonly maxEntries = 10_000,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.claimedAt.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claims an ID exactly once within its bounded retention window. */
|
||||||
|
claim(messageId: string, now = Date.now()): boolean {
|
||||||
|
this.prune(now);
|
||||||
|
if (this.claimedAt.has(messageId)) return false;
|
||||||
|
|
||||||
|
this.claimedAt.set(messageId, now);
|
||||||
|
this.evictOverflow();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(now: number): void {
|
||||||
|
for (const [messageId, claimedAt] of this.claimedAt) {
|
||||||
|
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictOverflow(): void {
|
||||||
|
while (this.claimedAt.size > this.maxEntries) {
|
||||||
|
const oldestMessageId = this.claimedAt.keys().next().value;
|
||||||
|
if (oldestMessageId === undefined) return;
|
||||||
|
this.claimedAt.delete(oldestMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
|||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
||||||
|
|
||||||
|
function requiredDiscordAllowlist(name: string): string[] {
|
||||||
|
const value = process.env[name]
|
||||||
|
?.split(',')
|
||||||
|
.map((id: string): string => id.trim())
|
||||||
|
.filter((id: string): boolean => id.length > 0);
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function createPluginRegistry(): IChannelPlugin[] {
|
function createPluginRegistry(): IChannelPlugin[] {
|
||||||
const plugins: IChannelPlugin[] = [];
|
const plugins: IChannelPlugin[] = [];
|
||||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||||
|
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
|
||||||
|
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
|
||||||
if (discordToken) {
|
if (discordToken) {
|
||||||
|
if (!discordServiceToken || !discordServiceUserId) {
|
||||||
|
throw new Error(
|
||||||
|
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
plugins.push(
|
plugins.push(
|
||||||
new DiscordChannelPluginAdapter(
|
new DiscordChannelPluginAdapter(
|
||||||
new DiscordPlugin({
|
new DiscordPlugin({
|
||||||
token: discordToken,
|
token: discordToken,
|
||||||
guildId: discordGuildId,
|
guildId: discordGuildId,
|
||||||
gatewayUrl: discordGatewayUrl,
|
gatewayUrl: discordGatewayUrl,
|
||||||
|
serviceToken: discordServiceToken,
|
||||||
|
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
|
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
|
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
|
||||||
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
|
|||||||
|
|
||||||
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
|
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
|
||||||
|
|
||||||
The objective is to ship **Tess** (from *tessera*, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
|
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
|
||||||
|
|
||||||
### Scope
|
### Scope
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa
|
|||||||
|
|
||||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
||||||
|
|
||||||
|
### Discord service ingress security
|
||||||
|
|
||||||
|
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|||||||
@@ -294,13 +294,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
### Plugins
|
### Plugins
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ---------------------- | -------------------------------------------------------------------------- |
|
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||||
|
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
|
||||||
|
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
|
||||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
||||||
|
| `DISCORD_ALLOWED_GUILD_IDS` | Required comma-separated Discord guild snowflake allowlist; default-deny |
|
||||||
|
| `DISCORD_ALLOWED_CHANNEL_IDS` | Required comma-separated Discord channel snowflake allowlist; default-deny |
|
||||||
|
| `DISCORD_ALLOWED_USER_IDS` | Required comma-separated Discord user snowflake allowlist; default-deny |
|
||||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||||
|
|
||||||
|
### Discord ingress security
|
||||||
|
|
||||||
|
When `DISCORD_BOT_TOKEN` is configured, `DISCORD_SERVICE_TOKEN`, `DISCORD_SERVICE_USER_ID`, and all three Discord allowlists are required. Gateway startup fails rather than enabling a broad or unauthenticated remote-control surface. The service user ID identifies a provisioned Mosaic service principal for persistence; the original Discord user ID is retained in ingress audit metadata. The service token is a secret supplied by the approved runtime secret mechanism and is never committed or logged.
|
||||||
|
|
||||||
|
Inbound Discord messages must originate from an allowed guild, channel, and user, mention the bot, and carry a signed envelope containing the native Discord message ID and a generated correlation ID. The gateway validates the service identity, envelope signature, and allowlists again before dispatching. Replayed Discord message IDs are rejected during the bounded ingress replay window. Durable inbox/idempotency retention is introduced with Tess durable state.
|
||||||
|
|
||||||
### Observability
|
### Observability
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|
|||||||
49
docs/scratchpads/703-wrapper-interactive-auth.md
Normal file
49
docs/scratchpads/703-wrapper-interactive-auth.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# #703 Git Wrapper Interactive and Auth Resilience
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `packages/mosaic/framework/tools/git/issue-create.sh`
|
||||||
|
- `packages/mosaic/framework/tools/git/detect-platform.sh`
|
||||||
|
- Git wrapper regression harnesses
|
||||||
|
- This scratchpad
|
||||||
|
|
||||||
|
## Requirements / acceptance evidence
|
||||||
|
|
||||||
|
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
|
||||||
|
2. Explicit command-line fields retain precedence and do not trigger prompt input.
|
||||||
|
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
|
||||||
|
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
|
||||||
|
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
|
||||||
|
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
|
||||||
|
2. Implement minimal helper and parser changes.
|
||||||
|
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
|
||||||
|
4. Re-seed deployed framework and run live wrapper verification.
|
||||||
|
5. Commit, queue guard, push, open PR, and stop for independent review.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
|
||||||
|
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
|
||||||
|
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
|
||||||
|
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
|
||||||
|
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
|
||||||
|
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
|
||||||
|
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
|
||||||
|
- PASS: Prettier check for this scratchpad
|
||||||
35
docs/scratchpads/tess-m1-sec-004-discord-ingress.md
Normal file
35
docs/scratchpads/tess-m1-sec-004-discord-ingress.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Scratchpad — TESS-M1-SEC-004 Discord ingress
|
||||||
|
|
||||||
|
- **Task / issue:** TESS-M1-SEC-004 / #707
|
||||||
|
- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd`
|
||||||
|
- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs.
|
||||||
|
- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs.
|
||||||
|
- **Budget:** Task estimate 28K; no explicit hard cap supplied.
|
||||||
|
- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection.
|
||||||
|
2. Implement the signed Discord ingress envelope and allowlist validation in the plugin.
|
||||||
|
3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch.
|
||||||
|
4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed.
|
||||||
|
- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks.
|
||||||
|
- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed.
|
||||||
|
- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped.
|
||||||
|
- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`.
|
||||||
|
- `pnpm typecheck` — passed.
|
||||||
|
- `pnpm lint` — passed.
|
||||||
|
- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification.
|
||||||
|
- Codex security review — no findings; final Codex code review — approved.
|
||||||
@@ -40,7 +40,7 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and
|
|||||||
## Authority Model
|
## Authority Model
|
||||||
|
|
||||||
| Intent | Owner | Tess behavior |
|
| Intent | Owner | Tess behavior |
|
||||||
| --- | --- | --- |
|
| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
|
||||||
| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy |
|
| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy |
|
||||||
| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result |
|
| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result |
|
||||||
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover.
|
Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover.
|
||||||
|
|
||||||
| Capability | Current source | Target | Initial status | Cutover/rollback intent |
|
| Capability | Current source | Target | Initial status | Cutover/rollback intent |
|
||||||
| --- | --- | --- | --- | --- |
|
| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- |
|
||||||
| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway |
|
| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway |
|
||||||
| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak |
|
| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak |
|
||||||
| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback |
|
| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback |
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag
|
|||||||
## Milestones
|
## Milestones
|
||||||
|
|
||||||
| ID | Issue | Name | Status | Exit gate |
|
| ID | Issue | Name | Status | Exit gate |
|
||||||
| --- | --- | --- | --- | --- |
|
| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
|
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
|
||||||
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
|
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
|
||||||
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
|
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
|
||||||
@@ -42,5 +42,5 @@ All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. T
|
|||||||
## Session History
|
## Session History
|
||||||
|
|
||||||
| Session | Date | Runtime | Outcome |
|
| Session | Date | Runtime | Outcome |
|
||||||
| --- | --- | --- | --- |
|
| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. |
|
| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. |
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
|
|||||||
## Threat Matrix
|
## Threat Matrix
|
||||||
|
|
||||||
| ID | Severity | Threat | Required control | Required verification |
|
| ID | Severity | Threat | Required control | Required verification |
|
||||||
| --- | --- | --- | --- | --- |
|
| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
|
||||||
| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit |
|
| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit |
|
||||||
| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods |
|
| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods |
|
||||||
| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned |
|
| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Tess Verification Matrix
|
# Tess Verification Matrix
|
||||||
|
|
||||||
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
||||||
| --- | --- | --- | --- |
|
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
||||||
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
||||||
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
||||||
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
||||||
|
|||||||
@@ -240,6 +240,33 @@ get_gitea_login_for_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Validate the current authenticated Gitea user for a resolved Tea login.
|
||||||
|
# Tea stores a user name with each login which can become stale after user rename,
|
||||||
|
# token rotation, or server migration. Querying /user derives the identity from the
|
||||||
|
# active credential instead of trusting that saved name. Callers fall back to the
|
||||||
|
# host-scoped API path when this validation fails.
|
||||||
|
get_gitea_authenticated_user() {
|
||||||
|
local login_name="$1" response
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
|
||||||
|
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if isinstance(login, str) and login:
|
||||||
|
print(login)
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
get_default_tea_login() {
|
get_default_tea_login() {
|
||||||
local logins_json
|
local logins_json
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ TITLE=""
|
|||||||
BODY=""
|
BODY=""
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
|
INTERACTIVE=false
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
@@ -66,11 +67,13 @@ Options:
|
|||||||
-b, --body BODY Issue body/description
|
-b, --body BODY Issue body/description
|
||||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||||
-m, --milestone NAME Milestone name to assign
|
-m, --milestone NAME Milestone name to assign
|
||||||
|
-i, --interactive Prompt for missing issue fields
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||||
|
$(basename "$0") -i
|
||||||
EOF
|
EOF
|
||||||
exit "${1:-1}"
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
@@ -94,6 +97,10 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-i|--interactive)
|
||||||
|
INTERACTIVE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage 0
|
usage 0
|
||||||
;;
|
;;
|
||||||
@@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ "$INTERACTIVE" == true ]]; then
|
||||||
|
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||||
|
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||||
|
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
|
||||||
|
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$TITLE" ]]; then
|
if [[ -z "$TITLE" ]]; then
|
||||||
echo "Error: Title is required (-t)" >&2
|
echo "Error: Title is required (-t)" >&2
|
||||||
usage
|
usage
|
||||||
@@ -127,6 +141,11 @@ case "$PLATFORM" in
|
|||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
|
gitea_issue_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
|||||||
@@ -183,6 +183,11 @@ case "$PLATFORM" in
|
|||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
|
gitea_pr_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ JSON
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "api" ]]; then
|
||||||
|
printf '%s\n' '{"login":"ci-bot"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||||
echo 'GetUserByName: simulated stale login failure' >&2
|
echo 'GetUserByName: simulated stale login failure' >&2
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ JSON
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "api" ]]; then
|
||||||
|
printf '%s\n' '{"login":"ci-bot"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||||
desc=""
|
desc=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|||||||
88
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh
Executable file
88
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" == "api" ]]; then
|
||||||
|
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
|
||||||
|
echo 'GetUserByName: stale configured user' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' '{"login":"current-user"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
printf '%s\n' '{"number":703}'
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
|
"$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||||
|
|
||||||
|
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
|
||||||
|
|
||||||
|
# Explicit values take precedence in interactive mode: no title input is
|
||||||
|
# supplied, but the wrapper still creates the issue with the explicit title.
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
|
||||||
|
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
|
||||||
|
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
|
||||||
|
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
|
||||||
|
if grep -q -- 'tea issue create' "$LOG_FILE"; then
|
||||||
|
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
|
||||||
|
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
|
||||||
|
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
|
||||||
|
if grep -q -- 'tea pr create' "$LOG_FILE"; then
|
||||||
|
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'issue-create interactive/auth regression harness passed'
|
||||||
@@ -1,24 +1,109 @@
|
|||||||
|
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
export interface DiscordPluginConfig {
|
export interface DiscordPluginConfig {
|
||||||
token: string;
|
token: string;
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
/** Which guild to bind to (single-guild only for v0.1.0) */
|
/** Shared service credential injected by the approved secret mechanism. */
|
||||||
|
serviceToken: string;
|
||||||
|
/** Which guild to bind to (single-guild only for v0.1.0). */
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
|
allowedGuildIds: readonly string[];
|
||||||
|
allowedChannelIds: readonly string[];
|
||||||
|
allowedUserIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordIngressPayload {
|
||||||
|
correlationId: string;
|
||||||
|
messageId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordIngressEnvelope {
|
||||||
|
payload: DiscordIngressPayload;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordIngressAllowlists {
|
||||||
|
guildIds: readonly string[];
|
||||||
|
channelIds: readonly string[];
|
||||||
|
userIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedPayload(payload: DiscordIngressPayload): string {
|
||||||
|
return [
|
||||||
|
payload.correlationId,
|
||||||
|
payload.messageId,
|
||||||
|
payload.guildId,
|
||||||
|
payload.channelId,
|
||||||
|
payload.userId,
|
||||||
|
payload.conversationId,
|
||||||
|
payload.content,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
|
||||||
|
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignatureValid(actual: string, expected: string): boolean {
|
||||||
|
const actualBuffer = Buffer.from(actual, 'hex');
|
||||||
|
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||||
|
return (
|
||||||
|
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesId(allowedIds: readonly string[], id: string): boolean {
|
||||||
|
return allowedIds.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
|
||||||
|
export function createDiscordIngressEnvelope(
|
||||||
|
payload: DiscordIngressPayload,
|
||||||
|
serviceToken: string,
|
||||||
|
): DiscordIngressEnvelope {
|
||||||
|
return { payload, signature: signPayload(payload, serviceToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
|
||||||
|
* Returns null instead of a partially trusted payload on every failure path.
|
||||||
|
*/
|
||||||
|
export function verifyDiscordIngressEnvelope(
|
||||||
|
envelope: DiscordIngressEnvelope,
|
||||||
|
serviceToken: string,
|
||||||
|
allowlists?: DiscordIngressAllowlists,
|
||||||
|
): DiscordIngressPayload | null {
|
||||||
|
const expectedSignature = signPayload(envelope.payload, serviceToken);
|
||||||
|
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
allowlists &&
|
||||||
|
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
|
||||||
|
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
|
||||||
|
!includesId(allowlists.userIds, envelope.payload.userId))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DiscordPlugin {
|
export class DiscordPlugin {
|
||||||
private client: Client;
|
private client: Client;
|
||||||
private socket: Socket | null = null;
|
private socket: Socket | null = null;
|
||||||
private config: DiscordPluginConfig;
|
/** Map Discord channel ID → Mosaic conversation ID. */
|
||||||
/** Map Discord channel ID → Mosaic conversation ID */
|
|
||||||
private channelConversations = new Map<string, string>();
|
private channelConversations = new Map<string, string>();
|
||||||
/** Track in-flight responses to avoid duplicate streaming */
|
/** Track in-flight responses to avoid duplicate streaming. */
|
||||||
private pendingResponses = new Map<string, string>();
|
private pendingResponses = new Map<string, string>();
|
||||||
|
|
||||||
constructor(config: DiscordPluginConfig) {
|
constructor(private readonly config: DiscordPluginConfig) {
|
||||||
this.config = config;
|
|
||||||
this.client = new Client({
|
this.client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
@@ -30,8 +115,8 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Connect to gateway WebSocket
|
|
||||||
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||||
|
auth: { discordServiceToken: this.config.serviceToken },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +133,6 @@ export class DiscordPlugin {
|
|||||||
console.error(`[discord] Gateway connection error: ${err.message}`);
|
console.error(`[discord] Gateway connection error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle streaming text from gateway
|
|
||||||
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||||
const pending = this.pendingResponses.get(data.conversationId);
|
const pending = this.pendingResponses.get(data.conversationId);
|
||||||
if (pending !== undefined) {
|
if (pending !== undefined) {
|
||||||
@@ -56,12 +140,11 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// When agent finishes, send the accumulated response
|
|
||||||
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||||
const text = this.pendingResponses.get(data.conversationId);
|
const text = this.pendingResponses.get(data.conversationId);
|
||||||
if (text) {
|
if (text) {
|
||||||
this.pendingResponses.delete(data.conversationId);
|
this.pendingResponses.delete(data.conversationId);
|
||||||
this.sendToDiscord(data.conversationId, text).catch((err) => {
|
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
|
||||||
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,8 +154,9 @@ export class DiscordPlugin {
|
|||||||
this.pendingResponses.set(data.conversationId, '');
|
this.pendingResponses.set(data.conversationId, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up Discord message handler
|
this.client.on('messageCreate', (message: DiscordMessage) =>
|
||||||
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
|
this.handleDiscordMessage(message),
|
||||||
|
);
|
||||||
|
|
||||||
this.client.on('ready', () => {
|
this.client.on('ready', () => {
|
||||||
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
||||||
@@ -96,7 +180,6 @@ export class DiscordPlugin {
|
|||||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||||
if (!guild) return null;
|
if (!guild) return null;
|
||||||
|
|
||||||
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
|
|
||||||
const channelName = `mosaic-${project.name
|
const channelName = `mosaic-${project.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
@@ -108,58 +191,58 @@ export class DiscordPlugin {
|
|||||||
topic: project.description ?? `Mosaic project: ${project.name}`,
|
topic: project.description ?? `Mosaic project: ${project.name}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the channel mapping so messages route correctly
|
|
||||||
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
||||||
|
|
||||||
return { channelId: channel.id };
|
return { channelId: channel.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDiscordMessage(message: DiscordMessage): void {
|
private handleDiscordMessage(message: DiscordMessage): void {
|
||||||
// Ignore bot messages
|
if (message.author.bot || !this.client.user) return;
|
||||||
if (message.author.bot) return;
|
if (!message.guildId || !this.isAllowedMessage(message)) return;
|
||||||
|
|
||||||
// Not ready yet
|
|
||||||
if (!this.client.user) return;
|
|
||||||
|
|
||||||
// Check guild binding
|
|
||||||
if (this.config.guildId && message.guildId !== this.config.guildId) return;
|
|
||||||
|
|
||||||
// Respond to DMs always, or mentions in channels
|
|
||||||
const isDM = !message.guildId;
|
|
||||||
const isMention = message.mentions.has(this.client.user);
|
const isMention = message.mentions.has(this.client.user);
|
||||||
|
if (!isMention) return;
|
||||||
|
|
||||||
if (!isDM && !isMention) return;
|
|
||||||
|
|
||||||
// Strip bot mention from message content
|
|
||||||
const content = message.content
|
const content = message.content
|
||||||
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
// Get or create conversation for this Discord channel
|
|
||||||
const channelId = message.channelId;
|
|
||||||
let conversationId = this.channelConversations.get(channelId);
|
|
||||||
if (!conversationId) {
|
|
||||||
conversationId = `discord-${channelId}`;
|
|
||||||
this.channelConversations.set(channelId, conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to gateway
|
|
||||||
if (!this.socket?.connected) {
|
if (!this.socket?.connected) {
|
||||||
console.error(
|
console.error(
|
||||||
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`,
|
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.socket.emit('message', {
|
|
||||||
|
const channelId = message.channelId;
|
||||||
|
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
|
||||||
|
this.channelConversations.set(channelId, conversationId);
|
||||||
|
|
||||||
|
const envelope = createDiscordIngressEnvelope(
|
||||||
|
{
|
||||||
|
correlationId: randomUUID(),
|
||||||
|
messageId: message.id,
|
||||||
|
guildId: message.guildId,
|
||||||
|
channelId,
|
||||||
|
userId: message.author.id,
|
||||||
conversationId,
|
conversationId,
|
||||||
content,
|
content,
|
||||||
});
|
},
|
||||||
|
this.config.serviceToken,
|
||||||
|
);
|
||||||
|
this.socket.emit('message', envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isAllowedMessage(message: DiscordMessage): boolean {
|
||||||
|
const guildId = message.guildId;
|
||||||
|
return (
|
||||||
|
guildId !== null &&
|
||||||
|
includesId(this.config.allowedGuildIds, guildId) &&
|
||||||
|
includesId(this.config.allowedChannelIds, message.channelId) &&
|
||||||
|
includesId(this.config.allowedUserIds, message.author.id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
||||||
// Find the Discord channel for this conversation
|
|
||||||
const channelId = Array.from(this.channelConversations.entries()).find(
|
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||||
([, convId]) => convId === conversationId,
|
([, convId]) => convId === conversationId,
|
||||||
)?.[0];
|
)?.[0];
|
||||||
@@ -177,12 +260,10 @@ export class DiscordPlugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunk responses for Discord's 2000-char limit
|
for (const chunk of this.chunkText(text, 1900)) {
|
||||||
const chunks = this.chunkText(text, 1900);
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
try {
|
try {
|
||||||
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,21 +274,16 @@ export class DiscordPlugin {
|
|||||||
|
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
let remaining = text;
|
let remaining = text;
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
while (remaining.length > 0) {
|
||||||
if (remaining.length <= maxLength) {
|
if (remaining.length <= maxLength) {
|
||||||
chunks.push(remaining);
|
chunks.push(remaining);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to break at a newline
|
|
||||||
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||||
if (breakPoint <= 0) breakPoint = maxLength;
|
if (breakPoint <= 0) breakPoint = maxLength;
|
||||||
|
|
||||||
chunks.push(remaining.slice(0, breakPoint));
|
chunks.push(remaining.slice(0, breakPoint));
|
||||||
remaining = remaining.slice(breakPoint).trimStart();
|
remaining = remaining.slice(breakPoint).trimStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user