Compare commits
1 Commits
55ae77b6fd
...
fix/tess-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbd9a1a199 |
@@ -7,14 +7,7 @@ variables:
|
||||
- &enable_pnpm 'corepack enable'
|
||||
|
||||
when:
|
||||
# PR + manual CI run on any branch — the pull_request pipeline is the merge gate.
|
||||
# push CI is restricted to protected branches (main) so a feature-branch push no
|
||||
# longer fires a redundant SECOND pipeline alongside its PR pipeline. This ~halves
|
||||
# CI load on the storage-constrained runner with zero loss of gating (branch
|
||||
# protection requires no push/ci status context; main still gets full push CI).
|
||||
- event: [pull_request, manual]
|
||||
- event: push
|
||||
branch: main
|
||||
- event: [push, pull_request, manual]
|
||||
|
||||
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||
|
||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
||||
async check(): Promise<HealthStatusDto> {
|
||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
||||
|
||||
const sessions = this.agentService.listAllSessionsForSystem();
|
||||
const sessions = this.agentService.listSessions();
|
||||
const providers = this.providerService.listProviders();
|
||||
|
||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AgentService, type AgentSession } from '../agent.service.js';
|
||||
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||
|
||||
const CONVERSATION_ID = '22222222-2222-4222-8222-222222222222';
|
||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-user', tenantId: 'owner-tenant' };
|
||||
const FOREIGN_SCOPE: ActorTenantScope = { userId: 'foreign-user', tenantId: 'foreign-tenant' };
|
||||
|
||||
type AgentServiceInternals = {
|
||||
sessions: Map<string, AgentSession>;
|
||||
creating: Map<string, Promise<AgentSession>>;
|
||||
};
|
||||
|
||||
function makeService(): AgentService {
|
||||
return new AgentService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ available: false } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
null,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
);
|
||||
}
|
||||
|
||||
function internals(service: AgentService): AgentServiceInternals {
|
||||
return service as unknown as AgentServiceInternals;
|
||||
}
|
||||
|
||||
function makeSession(scope: ActorTenantScope = OWNER_SCOPE): AgentSession {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'off',
|
||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||
setThinkingLevel: vi.fn(),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(),
|
||||
getContextUsage: vi.fn(),
|
||||
} as unknown as AgentSession['piSession'],
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: '/tmp/tess-session-ownership-test',
|
||||
allowedTools: null,
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentService owner/tenant scope enforcement', () => {
|
||||
it('allows owner-scoped operations and rejects foreign scopes for seeded sessions', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
internals(service).sessions.set(CONVERSATION_ID, session);
|
||||
|
||||
expect(service.getSession(CONVERSATION_ID, OWNER_SCOPE)).toBe(session);
|
||||
expect(service.getSession(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||
expect(service.getSessionInfo(CONVERSATION_ID, FOREIGN_SCOPE)).toBeUndefined();
|
||||
expect(service.listSessions(OWNER_SCOPE)).toHaveLength(1);
|
||||
expect(service.listSessions(FOREIGN_SCOPE)).toEqual([]);
|
||||
|
||||
service.addChannel(CONVERSATION_ID, 'websocket:owner', OWNER_SCOPE);
|
||||
expect(session.channels.has('websocket:owner')).toBe(true);
|
||||
expect(() => service.addChannel(CONVERSATION_ID, 'websocket:foreign', FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(() => service.removeChannel(CONVERSATION_ID, 'websocket:owner', FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
service.updateSessionModel(CONVERSATION_ID, 'foreign-model', FOREIGN_SCOPE),
|
||||
).toThrow(ForbiddenException);
|
||||
service.updateSessionModel(CONVERSATION_ID, 'owner-model', OWNER_SCOPE);
|
||||
expect(session.modelId).toBe('owner-model');
|
||||
|
||||
expect(() =>
|
||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-foreign', 'Foreign Agent', FOREIGN_SCOPE),
|
||||
).toThrow(ForbiddenException);
|
||||
service.applyAgentConfig(CONVERSATION_ID, 'agent-owner', 'Owner Agent', OWNER_SCOPE);
|
||||
expect(session.agentConfigId).toBe('agent-owner');
|
||||
|
||||
expect(() => service.onEvent(CONVERSATION_ID, vi.fn(), FOREIGN_SCOPE)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
const cleanup = service.onEvent(CONVERSATION_ID, vi.fn(), OWNER_SCOPE);
|
||||
cleanup();
|
||||
|
||||
await expect(
|
||||
service.prompt(CONVERSATION_ID, 'foreign prompt', FOREIGN_SCOPE),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await service.prompt(CONVERSATION_ID, 'owner prompt', OWNER_SCOPE);
|
||||
expect(session.piSession.prompt).toHaveBeenCalledWith('owner prompt');
|
||||
|
||||
await expect(service.destroySession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(true);
|
||||
|
||||
await service.destroySession(CONVERSATION_ID, OWNER_SCOPE);
|
||||
expect(session.piSession.dispose).toHaveBeenCalled();
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
internals(service).creating.set(CONVERSATION_ID, Promise.resolve(session));
|
||||
|
||||
await expect(
|
||||
service.createSession(CONVERSATION_ID, {
|
||||
userId: FOREIGN_SCOPE.userId,
|
||||
tenantId: FOREIGN_SCOPE.tenantId,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
await expect(
|
||||
service.createSession(CONVERSATION_ID, {
|
||||
userId: OWNER_SCOPE.userId,
|
||||
tenantId: OWNER_SCOPE.tenantId,
|
||||
}),
|
||||
).resolves.toBe(session);
|
||||
});
|
||||
});
|
||||
@@ -1,262 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../agent.service.js', () => ({ AgentService: class AgentService {} }));
|
||||
vi.mock('../../commands/command-executor.service.js', () => ({
|
||||
CommandExecutorService: class CommandExecutorService {},
|
||||
}));
|
||||
vi.mock('../routing/routing-engine.service.js', () => ({
|
||||
RoutingEngineService: class RoutingEngineService {},
|
||||
}));
|
||||
|
||||
import { SessionsController } from '../sessions.controller.js';
|
||||
import { ChatController } from '../../chat/chat.controller.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import type { AgentSession } from '../agent.service.js';
|
||||
import type { SessionInfoDto } from '../session.dto.js';
|
||||
|
||||
const USER_A = { id: 'user-a', tenantId: 'tenant-a' };
|
||||
const USER_B = { id: 'user-b', tenantId: 'tenant-b' };
|
||||
const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111';
|
||||
|
||||
function makeSessionInfo(overrides?: Partial<SessionInfoDto>): SessionInfoDto {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
createdAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
promptCount: 0,
|
||||
channels: [],
|
||||
durationMs: 0,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeAgentSession(owner = USER_A): AgentSession {
|
||||
return {
|
||||
id: CONVERSATION_ID,
|
||||
provider: 'test-provider',
|
||||
modelId: 'test-model',
|
||||
piSession: {
|
||||
thinkingLevel: 'off',
|
||||
getAvailableThinkingLevels: vi.fn().mockReturnValue(['off', 'low', 'high']),
|
||||
setThinkingLevel: vi.fn(),
|
||||
abort: vi.fn().mockResolvedValue(undefined),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(),
|
||||
getContextUsage: vi.fn(),
|
||||
} as unknown as AgentSession['piSession'],
|
||||
listeners: new Set(),
|
||||
unsubscribe: vi.fn(),
|
||||
createdAt: Date.now(),
|
||||
promptCount: 0,
|
||||
channels: new Set(),
|
||||
skillPromptAdditions: [],
|
||||
sandboxDir: '/tmp',
|
||||
allowedTools: null,
|
||||
userId: owner.id,
|
||||
tenantId: owner.tenantId,
|
||||
metrics: {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
modelSwitches: 0,
|
||||
messageCount: 0,
|
||||
lastActivityAt: new Date('2026-07-12T00:00:00Z').toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeScopedAgentService() {
|
||||
const foreign = makeAgentSession(USER_A);
|
||||
return {
|
||||
listSessions: vi.fn((scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? [] : [makeSessionInfo({ id: foreign.id })],
|
||||
),
|
||||
getSessionInfo: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? undefined : makeSessionInfo({ id: foreign.id }),
|
||||
),
|
||||
destroySession: vi.fn(),
|
||||
getSession: vi.fn((_id: string, scope?: { userId: string; tenantId?: string }) =>
|
||||
scope?.userId === USER_B.id ? undefined : foreign,
|
||||
),
|
||||
createSession: vi.fn().mockRejectedValue(new ForbiddenException('Session scope mismatch')),
|
||||
onEvent: vi.fn(() => vi.fn()),
|
||||
addChannel: vi.fn(),
|
||||
removeChannel: vi.fn(),
|
||||
recordMessage: vi.fn(),
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('TESS-M1-SEC-002 AgentService ownership boundary', () => {
|
||||
it('requires explicit owner+tenant scope on protected session operations', () => {
|
||||
const source = readFileSync(resolve('src/agent/agent.service.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('getSession(sessionId: string, scope: ActorTenantScope)');
|
||||
expect(source).toContain('listSessions(scope: ActorTenantScope)');
|
||||
expect(source).toContain('getSessionInfo(sessionId: string, scope: ActorTenantScope)');
|
||||
expect(source).toContain(
|
||||
'addChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'removeChannel(sessionId: string, channel: string, scope: ActorTenantScope)',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'async prompt(sessionId: string, message: string, scope: ActorTenantScope)',
|
||||
);
|
||||
expect(source).toContain('scope: ActorTenantScope,');
|
||||
expect(source).toContain('async destroySession(sessionId: string, scope: ActorTenantScope)');
|
||||
expect(source).not.toContain('scope?: ActorTenantScope');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST session ownership and tenant binding', () => {
|
||||
it('lists only sessions owned by the authenticated owner+tenant scope', () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new SessionsController(agentService as never);
|
||||
|
||||
expect(controller.list(USER_B)).toEqual({ sessions: [], total: 0 });
|
||||
expect(agentService.listSessions).toHaveBeenCalledWith({
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reveal another owner/tenant session by guessed id', () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new SessionsController(agentService as never);
|
||||
|
||||
expect(() => controller.findOne(CONVERSATION_ID, USER_B)).toThrow(NotFoundException);
|
||||
expect(agentService.getSessionInfo).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not terminate another owner/tenant session by guessed id', async () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new SessionsController(agentService as never);
|
||||
|
||||
await expect(controller.destroy(CONVERSATION_ID, USER_B)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(agentService.destroySession).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 REST chat send ownership and tenant binding', () => {
|
||||
it('does not send a prompt into another owner/tenant session by guessed conversationId', async () => {
|
||||
const agentService = makeScopedAgentService();
|
||||
const controller = new ChatController(agentService as never);
|
||||
|
||||
await expect(
|
||||
controller.chat({ conversationId: CONVERSATION_ID, content: 'take over' }, USER_B),
|
||||
).rejects.toMatchObject({ status: 404 });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TESS-M1-SEC-002 WebSocket session ownership and tenant binding', () => {
|
||||
function makeGateway(agentService = makeScopedAgentService()) {
|
||||
const brain = {
|
||||
conversations: {
|
||||
findById: vi.fn().mockResolvedValue(undefined),
|
||||
create: vi.fn().mockResolvedValue(undefined),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
findMessages: vi.fn().mockResolvedValue([]),
|
||||
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
const commandRegistry = { getManifest: vi.fn().mockReturnValue([]) };
|
||||
const commandExecutor = { execute: vi.fn() };
|
||||
const routingEngine = {
|
||||
resolve: vi.fn().mockResolvedValue({ provider: 'test', model: 'test-model' }),
|
||||
};
|
||||
const gateway = new ChatGateway(
|
||||
agentService as never,
|
||||
{} as never,
|
||||
brain as never,
|
||||
commandRegistry as never,
|
||||
commandExecutor as never,
|
||||
routingEngine as never,
|
||||
);
|
||||
return { gateway, agentService };
|
||||
}
|
||||
|
||||
function makeSocket() {
|
||||
return {
|
||||
id: 'socket-b',
|
||||
connected: true,
|
||||
data: { user: USER_B, session: { id: 'auth-session-b', userId: USER_B.id } },
|
||||
emit: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
it('does not attach or send to another owner/tenant session by guessed conversationId', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
await gateway.handleMessage(socket as never, {
|
||||
conversationId: CONVERSATION_ID,
|
||||
content: 'attach to foreign session',
|
||||
});
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(agentService.onEvent).not.toHaveBeenCalled();
|
||||
expect(agentService.addChannel).not.toHaveBeenCalled();
|
||||
expect(agentService.prompt).not.toHaveBeenCalled();
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mutate thinking level on another owner/tenant session', () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
gateway.handleSetThinking(socket as never, { conversationId: CONVERSATION_ID, level: 'high' });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not terminate another owner/tenant session over WebSocket abort', async () => {
|
||||
const { gateway, agentService } = makeGateway();
|
||||
const socket = makeSocket();
|
||||
|
||||
await gateway.handleAbort(socket as never, { conversationId: CONVERSATION_ID });
|
||||
|
||||
expect(agentService.getSession).toHaveBeenCalledWith(CONVERSATION_ID, {
|
||||
userId: USER_B.id,
|
||||
tenantId: USER_B.tenantId,
|
||||
});
|
||||
expect(socket.emit).toHaveBeenCalledWith(
|
||||
'error',
|
||||
expect.objectContaining({ conversationId: CONVERSATION_ID }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
Optional,
|
||||
type OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
|
||||
import {
|
||||
createAgentSession,
|
||||
DefaultResourceLoader,
|
||||
@@ -35,7 +28,6 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
|
||||
/** A single message from DB conversation history, used for context injection. */
|
||||
export interface ConversationHistoryMessage {
|
||||
@@ -76,8 +68,6 @@ export interface AgentSessionOptions {
|
||||
agentConfigId?: string;
|
||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||
userId?: string;
|
||||
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
||||
tenantId?: string;
|
||||
/**
|
||||
* Prior conversation messages to inject as context when resuming a session.
|
||||
* These messages are formatted and prepended to the system prompt so the
|
||||
@@ -104,8 +94,6 @@ export interface AgentSession {
|
||||
allowedTools: string[] | null;
|
||||
/** User ID that owns this session, used for preference lookups. */
|
||||
userId?: string;
|
||||
/** Server-derived tenant scope that owns this session. Falls back to userId for solo users. */
|
||||
tenantId?: string;
|
||||
/** Agent config ID applied to this session, if any (M5-001). */
|
||||
agentConfigId?: string;
|
||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
||||
@@ -186,20 +174,12 @@ export class AgentService implements OnModuleDestroy {
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
|
||||
const scope = this.scopeFromOptions(options);
|
||||
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
|
||||
const existing = this.sessions.get(sessionId);
|
||||
if (existing) {
|
||||
this.assertSessionScope(existing, scope);
|
||||
return existing;
|
||||
}
|
||||
if (existing) return existing;
|
||||
|
||||
const inflight = this.creating.get(sessionId);
|
||||
if (inflight) {
|
||||
const session = await inflight;
|
||||
this.assertSessionScope(session, scope);
|
||||
return session;
|
||||
}
|
||||
if (inflight) return inflight;
|
||||
|
||||
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||
this.creating.delete(sessionId);
|
||||
@@ -362,7 +342,6 @@ export class AgentService implements OnModuleDestroy {
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
||||
agentConfigId: mergedOptions?.agentConfigId,
|
||||
agentName: resolvedAgentName,
|
||||
metrics: {
|
||||
@@ -494,70 +473,38 @@ export class AgentService implements OnModuleDestroy {
|
||||
return this.providerService.getDefaultModel() ?? null;
|
||||
}
|
||||
|
||||
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
|
||||
return session;
|
||||
getSession(sessionId: string): AgentSession | undefined {
|
||||
return this.sessions.get(sessionId);
|
||||
}
|
||||
|
||||
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
|
||||
listSessions(): SessionInfoDto[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.sessions.values())
|
||||
.filter((s) => this.sessionMatchesScope(s, scope))
|
||||
.map((s) => this.toSessionInfo(s, now));
|
||||
return Array.from(this.sessions.values()).map((s) => ({
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: now - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
}));
|
||||
}
|
||||
|
||||
listAllSessionsForSystem(): SessionInfoDto[] {
|
||||
const now = Date.now();
|
||||
return Array.from(this.sessions.values()).map((s) => this.toSessionInfo(s, now));
|
||||
}
|
||||
|
||||
getSessionInfo(sessionId: string, scope: ActorTenantScope): SessionInfoDto | undefined {
|
||||
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
|
||||
const s = this.sessions.get(sessionId);
|
||||
if (!s || !this.sessionMatchesScope(s, scope)) return undefined;
|
||||
return this.toSessionInfo(s);
|
||||
}
|
||||
|
||||
private scopeFromOptions(options: AgentSessionOptions): ActorTenantScope {
|
||||
if (!options.userId) {
|
||||
throw new ForbiddenException('Session owner scope is required');
|
||||
}
|
||||
if (!s) return undefined;
|
||||
return {
|
||||
userId: options.userId,
|
||||
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private tenantIdFor(
|
||||
userId: string | undefined,
|
||||
tenantId: string | undefined,
|
||||
): string | undefined {
|
||||
return tenantId ?? userId;
|
||||
}
|
||||
|
||||
private sessionMatchesScope(session: AgentSession, scope: ActorTenantScope): boolean {
|
||||
return (
|
||||
session.userId === scope.userId && (session.tenantId ?? session.userId) === scope.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
private assertSessionScope(session: AgentSession, scope: ActorTenantScope): void {
|
||||
if (!this.sessionMatchesScope(session, scope)) {
|
||||
throw new ForbiddenException('Session does not belong to the current owner/tenant scope');
|
||||
}
|
||||
}
|
||||
|
||||
private toSessionInfo(session: AgentSession, now = Date.now()): SessionInfoDto {
|
||||
return {
|
||||
id: session.id,
|
||||
provider: session.provider,
|
||||
modelId: session.modelId,
|
||||
...(session.agentName ? { agentName: session.agentName } : {}),
|
||||
createdAt: new Date(session.createdAt).toISOString(),
|
||||
promptCount: session.promptCount,
|
||||
channels: Array.from(session.channels),
|
||||
durationMs: now - session.createdAt,
|
||||
metrics: { ...session.metrics },
|
||||
id: s.id,
|
||||
provider: s.provider,
|
||||
modelId: s.modelId,
|
||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
||||
createdAt: new Date(s.createdAt).toISOString(),
|
||||
promptCount: s.promptCount,
|
||||
channels: Array.from(s.channels),
|
||||
durationMs: Date.now() - s.createdAt,
|
||||
metrics: { ...s.metrics },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -606,10 +553,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
* not reconstructed — the model is used on the next createSession call for
|
||||
* the same conversationId when the session is torn down or a new one is created.
|
||||
*/
|
||||
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
|
||||
updateSessionModel(sessionId: string, modelId: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
const prev = session.modelId;
|
||||
session.modelId = modelId;
|
||||
this.recordModelSwitch(sessionId);
|
||||
@@ -626,51 +572,48 @@ export class AgentService implements OnModuleDestroy {
|
||||
sessionId: string,
|
||||
agentConfigId: string,
|
||||
agentName: string,
|
||||
scope: ActorTenantScope,
|
||||
modelId?: string,
|
||||
): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.agentConfigId = agentConfigId;
|
||||
session.agentName = agentName;
|
||||
if (modelId) {
|
||||
this.updateSessionModel(sessionId, modelId, scope);
|
||||
this.updateSessionModel(sessionId, modelId);
|
||||
}
|
||||
this.logger.log(
|
||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
||||
);
|
||||
}
|
||||
|
||||
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||
addChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.channels.add(channel);
|
||||
if (session) {
|
||||
session.channels.add(channel);
|
||||
}
|
||||
}
|
||||
|
||||
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
|
||||
removeChannel(sessionId: string, channel: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
session.channels.delete(channel);
|
||||
if (session) {
|
||||
session.channels.delete(channel);
|
||||
}
|
||||
}
|
||||
|
||||
async prompt(sessionId: string, message: string, scope: ActorTenantScope): Promise<void> {
|
||||
async prompt(sessionId: string, message: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.promptCount += 1;
|
||||
|
||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||
let effectiveMessage = message;
|
||||
if (this.systemOverride) {
|
||||
const override = await this.systemOverride.get(sessionId, scope);
|
||||
const override = await this.systemOverride.get(sessionId);
|
||||
if (override) {
|
||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
||||
await this.systemOverride.renew(sessionId, scope);
|
||||
await this.systemOverride.renew(sessionId);
|
||||
this.logger.debug(`Applied system override for session ${sessionId}`);
|
||||
}
|
||||
}
|
||||
@@ -686,28 +629,16 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
onEvent(
|
||||
sessionId: string,
|
||||
listener: (event: AgentSessionEvent) => void,
|
||||
scope: ActorTenantScope,
|
||||
): () => void {
|
||||
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`No agent session found: ${sessionId}`);
|
||||
}
|
||||
this.assertSessionScope(session, scope);
|
||||
session.listeners.add(listener);
|
||||
return () => session.listeners.delete(listener);
|
||||
}
|
||||
|
||||
async destroySession(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.assertSessionScope(session, scope);
|
||||
await this.destroySessionForSystem(sessionId);
|
||||
}
|
||||
|
||||
private async destroySessionForSystem(sessionId: string): Promise<void> {
|
||||
async destroySession(sessionId: string): Promise<void> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||
@@ -736,7 +667,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.logger.log('Shutting down all agent sessions');
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
|
||||
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySession(id));
|
||||
const results = await Promise.allSettled(stops);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { AgentService } from './agent.service.js';
|
||||
|
||||
@Controller('api/sessions')
|
||||
@@ -20,24 +18,23 @@ export class SessionsController {
|
||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthenticatedUserLike) {
|
||||
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
||||
list() {
|
||||
const sessions = this.agentService.listSessions();
|
||||
return { sessions, total: sessions.length };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
||||
findOne(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
return info;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||
const scope = scopeFromUser(user);
|
||||
const info = this.agentService.getSessionInfo(id, scope);
|
||||
async destroy(@Param('id') id: string) {
|
||||
const info = this.agentService.getSessionInfo(id);
|
||||
if (!info) throw new NotFoundException('Session not found');
|
||||
await this.agentService.destroySession(id, scope);
|
||||
await this.agentService.destroySession(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
export interface AuthenticatedUserLike {
|
||||
id: string;
|
||||
tenantId?: string | null;
|
||||
teamId?: string | null;
|
||||
organizationId?: string | null;
|
||||
orgId?: string | null;
|
||||
}
|
||||
|
||||
export interface ActorTenantScope {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable server-derived scope used for Tess session operations.
|
||||
* Current Mosaic auth is user-scoped; future org/team claims can populate one
|
||||
* of the tenant fields without allowing clients to choose another tenant.
|
||||
*/
|
||||
export function scopeFromUser(user: AuthenticatedUserLike): ActorTenantScope {
|
||||
return {
|
||||
userId: user.id,
|
||||
tenantId: user.tenantId ?? user.teamId ?? user.organizationId ?? user.orgId ?? user.id,
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,7 @@ describe('Chat controller source hardening', () => {
|
||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||
|
||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
||||
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
|
||||
expect(source).toContain('const scope = scopeFromUser(user);');
|
||||
expect(source).toContain('@CurrentUser() user: { id: string }');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Inject,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -15,7 +13,6 @@ import { Throttle } from '@nestjs/throttler';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatRequestDto } from './chat.dto.js';
|
||||
|
||||
@@ -35,23 +32,16 @@ export class ChatController {
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
async chat(
|
||||
@Body() body: ChatRequestDto,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@CurrentUser() user: { id: string },
|
||||
): Promise<ChatResponse> {
|
||||
const conversationId = body.conversationId ?? uuid();
|
||||
const scope = scopeFromUser(user);
|
||||
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
agentSession = await this.agentService.createSession(conversationId, {
|
||||
userId: scope.userId,
|
||||
tenantId: scope.tenantId,
|
||||
});
|
||||
agentSession = await this.agentService.createSession(conversationId);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ForbiddenException) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
this.logger.error(
|
||||
`Session creation failed for conversation=${conversationId}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
@@ -70,27 +60,20 @@ export class ChatController {
|
||||
reject(new Error('Agent response timed out'));
|
||||
}, 120_000);
|
||||
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
if (
|
||||
event.type === 'message_update' &&
|
||||
event.assistantMessageEvent.type === 'text_delta'
|
||||
) {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
scope,
|
||||
);
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
||||
responseText += event.assistantMessageEvent.delta;
|
||||
}
|
||||
if (event.type === 'agent_end') {
|
||||
clearTimeout(timer);
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, body.content, scope);
|
||||
await this.agentService.prompt(conversationId, body.content);
|
||||
await done;
|
||||
} catch (err) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import type { IncomingHttpHeaders } from 'node:http';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
|
||||
@@ -13,19 +12,6 @@ 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(
|
||||
headers: IncomingHttpHeaders,
|
||||
auth: SessionAuth,
|
||||
|
||||
@@ -11,11 +11,6 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
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 { Brain } from '@mosaicstack/brain';
|
||||
import type {
|
||||
@@ -27,19 +22,13 @@ import type {
|
||||
} from '@mosaicstack/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import {
|
||||
scopeFromUser,
|
||||
type ActorTenantScope,
|
||||
type AuthenticatedUserLike,
|
||||
} from '../auth/session-scope.js';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
|
||||
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
|
||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
||||
|
||||
/** Per-client state tracking streaming accumulation for persistence. */
|
||||
interface ClientSession {
|
||||
@@ -51,8 +40,6 @@ interface ClientSession {
|
||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||
/** Tool calls in-flight (started but not ended yet). */
|
||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
||||
/** Server-derived owner/tenant scope for this socket's conversation attachment. */
|
||||
scope: ActorTenantScope;
|
||||
/** Last routing decision made for this session (M4-008) */
|
||||
lastRoutingDecision?: RoutingDecisionInfo;
|
||||
}
|
||||
@@ -63,37 +50,6 @@ interface ClientSession {
|
||||
*/
|
||||
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({
|
||||
cors: {
|
||||
origin: process.env['GATEWAY_CORS_ORIGIN'] ?? 'http://localhost:3000',
|
||||
@@ -106,7 +62,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
private readonly logger = new Logger(ChatGateway.name);
|
||||
private readonly clientSessions = new Map<string, ClientSession>();
|
||||
private readonly discordReplayProtector = new DiscordReplayProtector();
|
||||
|
||||
constructor(
|
||||
@Inject(AgentService) private readonly agentService: AgentService,
|
||||
@@ -122,13 +77,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
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);
|
||||
if (!session) {
|
||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||
@@ -139,6 +87,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
client.data.user = session.user;
|
||||
client.data.session = session.session;
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
|
||||
// Broadcast command manifest to the newly connected client
|
||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||
}
|
||||
|
||||
@@ -147,80 +97,25 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
const session = this.clientSessions.get(client.id);
|
||||
if (session) {
|
||||
session.cleanup();
|
||||
this.agentService.removeChannel(
|
||||
session.conversationId,
|
||||
`websocket:${client.id}`,
|
||||
session.scope,
|
||||
);
|
||||
this.agentService.removeChannel(session.conversationId, `websocket:${client.id}`);
|
||||
this.clientSessions.delete(client.id);
|
||||
}
|
||||
}
|
||||
|
||||
private getClientScope(client: Socket): ActorTenantScope | null {
|
||||
const user = client.data.user as AuthenticatedUserLike | undefined;
|
||||
if (!user?.id) return null;
|
||||
return scopeFromUser(user);
|
||||
}
|
||||
|
||||
private modelOverrideKey(conversationId: string, scope: ActorTenantScope): string {
|
||||
return `${scope.tenantId}:${scope.userId}:${conversationId}`;
|
||||
}
|
||||
|
||||
private scopesEqual(a: ActorTenantScope, b: ActorTenantScope): boolean {
|
||||
return a.userId === b.userId && a.tenantId === b.tenantId;
|
||||
}
|
||||
|
||||
@SubscribeMessage('message')
|
||||
async handleMessage(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() rawData: unknown,
|
||||
@MessageBody() data: ChatSocketMessageDto,
|
||||
): 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 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 scope = discordIngress
|
||||
? {
|
||||
userId: discordServiceUserId!,
|
||||
tenantId: process.env['DISCORD_SERVICE_TENANT_ID'] ?? discordServiceUserId!,
|
||||
}
|
||||
: this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||
return;
|
||||
}
|
||||
const userId = scope.userId;
|
||||
const correlationId = discordIngress?.correlationId;
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
||||
|
||||
this.logger.log(
|
||||
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
|
||||
);
|
||||
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
|
||||
|
||||
// Ensure agent session exists for this conversation
|
||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||
try {
|
||||
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||
let agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) {
|
||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||
@@ -240,7 +135,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
let resolvedProvider = data.provider;
|
||||
let resolvedModelId = data.modelId;
|
||||
|
||||
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||
const modelOverride = modelOverrides.get(conversationId);
|
||||
if (modelOverride) {
|
||||
// /model override bypasses routing engine (M4-007)
|
||||
resolvedModelId = modelOverride;
|
||||
@@ -277,7 +172,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
modelId: resolvedModelId,
|
||||
agentConfigId: data.agentId,
|
||||
userId,
|
||||
tenantId: scope.tenantId,
|
||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||
});
|
||||
|
||||
@@ -319,13 +213,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
content: data.content,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
...(correlationId
|
||||
? {
|
||||
correlationId,
|
||||
discordMessageId: discordIngress?.messageId,
|
||||
discordUserId: discordIngress?.userId,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
userId,
|
||||
@@ -345,13 +232,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
// Subscribe to agent events and relay to client
|
||||
const cleanup = this.agentService.onEvent(
|
||||
conversationId,
|
||||
(event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
},
|
||||
scope,
|
||||
);
|
||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
||||
this.relayEvent(client, conversationId, event);
|
||||
});
|
||||
|
||||
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||
const prevClientSession = this.clientSessions.get(client.id);
|
||||
@@ -363,17 +246,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
assistantText: '',
|
||||
toolCalls: [],
|
||||
pendingToolCalls: new Map(),
|
||||
scope,
|
||||
lastRoutingDecision: routingDecisionToStore,
|
||||
});
|
||||
|
||||
// Track channel connection
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
|
||||
this.agentService.addChannel(conversationId, `websocket:${client.id}`);
|
||||
|
||||
// Send session info so the client knows the model/provider (M4-008: include routing decision)
|
||||
// Include agentName when a named agent config is active (M5-001)
|
||||
{
|
||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (agentSession) {
|
||||
const piSession = agentSession.piSession;
|
||||
client.emit('session:info', {
|
||||
@@ -389,21 +271,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
}
|
||||
|
||||
// Send acknowledgment
|
||||
client.emit('message:ack', {
|
||||
conversationId,
|
||||
messageId: uuid(),
|
||||
...(correlationId
|
||||
? {
|
||||
correlationId,
|
||||
discordMessageId: discordIngress?.messageId,
|
||||
discordUserId: discordIngress?.userId,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
||||
|
||||
// Dispatch to agent
|
||||
try {
|
||||
await this.agentService.prompt(conversationId, data.content, scope);
|
||||
await this.agentService.prompt(conversationId, data.content);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||
@@ -421,16 +293,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: SetThinkingPayload,
|
||||
): void {
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
error: 'Authenticated user scope is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.agentService.getSession(data.conversationId, scope);
|
||||
const session = this.agentService.getSession(data.conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId: data.conversationId,
|
||||
@@ -471,13 +334,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
const conversationId = data.conversationId;
|
||||
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
|
||||
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('error', { conversationId, error: 'Authenticated user scope is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
client.emit('error', {
|
||||
conversationId,
|
||||
@@ -506,18 +363,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() payload: SlashCommandPayload,
|
||||
): Promise<void> {
|
||||
const scope = this.getClientScope(client);
|
||||
if (!scope) {
|
||||
client.emit('command:result', {
|
||||
command: payload.command,
|
||||
conversationId: payload.conversationId,
|
||||
success: false,
|
||||
message: 'Authenticated user scope is required.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.commandExecutor.execute(payload, scope);
|
||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
||||
const result = await this.commandExecutor.execute(payload, userId);
|
||||
client.emit('command:result', result);
|
||||
}
|
||||
|
||||
@@ -533,23 +380,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* M5-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
||||
* M5-007: Records a model switch in session metrics.
|
||||
*/
|
||||
setModelOverride(
|
||||
conversationId: string,
|
||||
modelName: string | null,
|
||||
scope: ActorTenantScope,
|
||||
): void {
|
||||
const key = this.modelOverrideKey(conversationId, scope);
|
||||
setModelOverride(conversationId: string, modelName: string | null): void {
|
||||
if (modelName) {
|
||||
modelOverrides.set(key, modelName);
|
||||
modelOverrides.set(conversationId, modelName);
|
||||
this.logger.log(`Model override set: conversation=${conversationId} model="${modelName}"`);
|
||||
|
||||
// M5-002: Update the live session's modelId so session:info reflects the new model immediately
|
||||
this.agentService.updateSessionModel(conversationId, modelName, scope);
|
||||
this.agentService.updateSessionModel(conversationId, modelName);
|
||||
|
||||
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||
this.broadcastSessionInfo(conversationId, scope);
|
||||
this.broadcastSessionInfo(conversationId);
|
||||
} else {
|
||||
modelOverrides.delete(key);
|
||||
modelOverrides.delete(conversationId);
|
||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||
}
|
||||
}
|
||||
@@ -557,8 +399,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
/**
|
||||
* Return the active model override for a conversation, or undefined if none.
|
||||
*/
|
||||
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
|
||||
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||
getModelOverride(conversationId: string): string | undefined {
|
||||
return modelOverrides.get(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -567,10 +409,9 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
*/
|
||||
broadcastSessionInfo(
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||
): void {
|
||||
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
if (!agentSession) return;
|
||||
|
||||
const piSession = agentSession.piSession;
|
||||
@@ -587,7 +428,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
// Emit to all clients currently subscribed to this conversation
|
||||
for (const [clientId, session] of this.clientSessions) {
|
||||
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
|
||||
if (session.conversationId === conversationId) {
|
||||
const socket = this.server.sockets.sockets.get(clientId);
|
||||
if (socket?.connected) {
|
||||
socket.emit('session:info', payload);
|
||||
@@ -601,39 +442,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||
* would fail on the PK constraint and be caught here.
|
||||
*/
|
||||
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> {
|
||||
try {
|
||||
const existing = await this.brain.conversations.findById(conversationId, userId);
|
||||
@@ -742,10 +550,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
|
||||
case 'agent_end': {
|
||||
// Gather usage stats from the Pi session
|
||||
const activeClientSession = this.clientSessions.get(client.id);
|
||||
const agentSession = activeClientSession
|
||||
? this.agentService.getSession(conversationId, activeClientSession.scope)
|
||||
: undefined;
|
||||
const agentSession = this.agentService.getSession(conversationId);
|
||||
const piSession = agentSession?.piSession;
|
||||
const stats = piSession?.getSessionStats();
|
||||
const contextUsage = piSession?.getContextUsage();
|
||||
|
||||
@@ -89,7 +89,6 @@ function buildService(): CommandExecutorService {
|
||||
describe('CommandExecutorService — P8-012 commands', () => {
|
||||
let service: CommandExecutorService;
|
||||
const userId = 'user-123';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-456';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -100,7 +99,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider login — missing provider name
|
||||
it('/provider login with no provider name returns usage error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider login');
|
||||
expect(result.command).toBe('provider');
|
||||
@@ -113,7 +112,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'login anthropic',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
expect(result.message).toContain('anthropic');
|
||||
@@ -139,7 +138,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider with no args — returns usage
|
||||
it('/provider with no args returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /provider');
|
||||
});
|
||||
@@ -147,7 +146,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider list
|
||||
it('/provider list returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('provider');
|
||||
});
|
||||
@@ -155,7 +154,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /provider logout with no name — usage error
|
||||
it('/provider logout with no name returns error', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Usage: /provider logout');
|
||||
});
|
||||
@@ -167,7 +166,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'unknown',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('Unknown subcommand');
|
||||
});
|
||||
@@ -175,7 +174,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission status
|
||||
it('/mission status returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('mission');
|
||||
expect(result.message).toContain('Mission status');
|
||||
@@ -184,7 +183,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /mission with no args
|
||||
it('/mission with no args returns status stub', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Mission status');
|
||||
});
|
||||
@@ -196,7 +195,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'set my-mission-123',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-mission-123');
|
||||
});
|
||||
@@ -204,7 +203,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent list
|
||||
it('/agent list returns stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('agent');
|
||||
expect(result.message).toContain('agent');
|
||||
@@ -213,7 +212,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /agent with no args
|
||||
it('/agent with no args returns usage', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage: /agent');
|
||||
});
|
||||
@@ -225,7 +224,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
args: 'my-agent-id',
|
||||
conversationId,
|
||||
};
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('my-agent-id');
|
||||
});
|
||||
@@ -233,7 +232,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /prdy
|
||||
it('/prdy returns PRD wizard message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('prdy');
|
||||
expect(result.message).toContain('mosaic prdy');
|
||||
@@ -242,7 +241,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
||||
// /tools
|
||||
it('/tools returns tools stub message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
||||
const result = await service.execute(payload, userScope);
|
||||
const result = await service.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('tools');
|
||||
expect(result.message).toContain('tools');
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||
import { AgentService } from '../agent/agent.service.js';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||
@@ -35,12 +34,8 @@ export class CommandExecutorService {
|
||||
private readonly mcpClient: McpClientService | null,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
payload: SlashCommandPayload,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
||||
const { command, args, conversationId } = payload;
|
||||
const userId = scope.userId;
|
||||
|
||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||
if (!def) {
|
||||
@@ -55,11 +50,11 @@ export class CommandExecutorService {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'model':
|
||||
return await this.handleModel(args ?? null, conversationId, scope);
|
||||
return await this.handleModel(args ?? null, conversationId);
|
||||
case 'thinking':
|
||||
return await this.handleThinking(args ?? null, conversationId);
|
||||
case 'system':
|
||||
return await this.handleSystem(args ?? null, conversationId, scope);
|
||||
return await this.handleSystem(args ?? null, conversationId);
|
||||
case 'new':
|
||||
return {
|
||||
command,
|
||||
@@ -99,7 +94,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
case 'agent':
|
||||
return await this.handleAgent(args ?? null, conversationId, scope);
|
||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
||||
case 'provider':
|
||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||
case 'mission':
|
||||
@@ -151,11 +146,10 @@ export class CommandExecutorService {
|
||||
private async handleModel(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Show current override or usage hint
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
||||
if (currentOverride) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -177,7 +171,7 @@ export class CommandExecutorService {
|
||||
|
||||
// /model clear removes the override and re-enables automatic routing
|
||||
if (modelName === 'clear') {
|
||||
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
||||
this.chatGateway?.setModelOverride(conversationId, null);
|
||||
return {
|
||||
command: 'model',
|
||||
conversationId,
|
||||
@@ -187,9 +181,9 @@ export class CommandExecutorService {
|
||||
}
|
||||
|
||||
// Set the sticky per-session override (M4-007)
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
|
||||
this.chatGateway?.setModelOverride(conversationId, modelName);
|
||||
|
||||
const session = this.agentService.getSession(conversationId, scope);
|
||||
const session = this.agentService.getSession(conversationId);
|
||||
if (!session) {
|
||||
return {
|
||||
command: 'model',
|
||||
@@ -230,11 +224,10 @@ export class CommandExecutorService {
|
||||
private async handleSystem(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
if (!args || args.trim().length === 0) {
|
||||
// Clear the override when called with no args
|
||||
await this.systemOverride.clear(conversationId, scope);
|
||||
await this.systemOverride.clear(conversationId);
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -243,7 +236,7 @@ export class CommandExecutorService {
|
||||
};
|
||||
}
|
||||
|
||||
await this.systemOverride.set(conversationId, args.trim(), scope);
|
||||
await this.systemOverride.set(conversationId, args.trim());
|
||||
return {
|
||||
command: 'system',
|
||||
conversationId,
|
||||
@@ -255,9 +248,8 @@ export class CommandExecutorService {
|
||||
private async handleAgent(
|
||||
args: string | null,
|
||||
conversationId: string,
|
||||
scope: ActorTenantScope,
|
||||
userId: string,
|
||||
): Promise<SlashCommandResultPayload> {
|
||||
const userId = scope.userId;
|
||||
if (!args) {
|
||||
return {
|
||||
command: 'agent',
|
||||
@@ -346,14 +338,11 @@ export class CommandExecutorService {
|
||||
conversationId,
|
||||
agentConfig.id,
|
||||
agentConfig.name,
|
||||
scope,
|
||||
agentConfig.model ?? undefined,
|
||||
);
|
||||
|
||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
|
||||
agentName: agentConfig.name,
|
||||
});
|
||||
this.chatGateway?.broadcastSessionInfo(conversationId, { agentName: agentConfig.name });
|
||||
|
||||
this.logger.log(
|
||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||
|
||||
@@ -159,7 +159,6 @@ describe('CommandExecutorService — integration', () => {
|
||||
let registry: CommandRegistryService;
|
||||
let executor: CommandExecutorService;
|
||||
const userId = 'user-integ-001';
|
||||
const userScope = { userId, tenantId: userId };
|
||||
const conversationId = 'conv-integ-001';
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -171,7 +170,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// Unknown command returns error
|
||||
it('unknown command returns success:false with descriptive message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('nonexistent');
|
||||
expect(result.command).toBe('nonexistent');
|
||||
@@ -180,7 +179,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('GC sweep complete');
|
||||
@@ -191,8 +190,8 @@ describe('CommandExecutorService — integration', () => {
|
||||
it('/system with text calls SystemOverrideService.set', async () => {
|
||||
const override = 'You are a helpful assistant.';
|
||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('override set');
|
||||
});
|
||||
@@ -200,8 +199,8 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /system with no args clears the override
|
||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('cleared');
|
||||
});
|
||||
@@ -213,7 +212,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
args: 'claude-3-opus',
|
||||
conversationId,
|
||||
};
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('model');
|
||||
expect(result.message).toContain('claude-3-opus');
|
||||
@@ -222,7 +221,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with valid level returns success
|
||||
it('/thinking with valid level returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('high');
|
||||
});
|
||||
@@ -230,7 +229,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /thinking with invalid level returns usage message
|
||||
it('/thinking with invalid level returns usage message', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Usage:');
|
||||
});
|
||||
@@ -238,7 +237,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /new command returns success
|
||||
it('/new returns success', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe('new');
|
||||
});
|
||||
@@ -246,7 +245,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
// /reload without reloadService returns failure
|
||||
it('/reload without ReloadService returns failure', async () => {
|
||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toContain('ReloadService');
|
||||
});
|
||||
@@ -256,7 +255,7 @@ describe('CommandExecutorService — integration', () => {
|
||||
for (const cmd of stubCommands) {
|
||||
it(`/${cmd} returns success (stub)`, async () => {
|
||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
||||
const result = await executor.execute(payload, userScope);
|
||||
const result = await executor.execute(payload, userId);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.command).toBe(cmd);
|
||||
});
|
||||
|
||||
@@ -3,11 +3,7 @@ import { Logger } from '@nestjs/common';
|
||||
import { fromNodeHeaders } from 'better-auth/node';
|
||||
import type { Auth } from '@mosaicstack/auth';
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import {
|
||||
createMcpActorContext,
|
||||
deriveMcpToolScopesForUser,
|
||||
type McpService,
|
||||
} from './mcp.service.js';
|
||||
import type { McpService } from './mcp.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
|
||||
/**
|
||||
@@ -71,25 +67,14 @@ async function handleMcpRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
const authUser = result.user as {
|
||||
id: string;
|
||||
role?: string | null;
|
||||
tenantId?: string | null;
|
||||
organizationId?: string | null;
|
||||
};
|
||||
const actor = createMcpActorContext({
|
||||
userId: authUser.id,
|
||||
role: authUser.role,
|
||||
tenantId: authUser.tenantId ?? authUser.organizationId ?? undefined,
|
||||
scopes: deriveMcpToolScopesForUser({ role: authUser.role }),
|
||||
});
|
||||
const userId = result.user.id;
|
||||
|
||||
// ─── Session routing ─────────────────────────────────────────────────────
|
||||
const sessionId = req.raw.headers['mcp-session-id'];
|
||||
|
||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||
// Existing session request
|
||||
const transport = mcpService.getSession(sessionId, actor);
|
||||
const transport = mcpService.getSession(sessionId);
|
||||
if (!transport) {
|
||||
logger.warn(`MCP session not found: ${sessionId}`);
|
||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
@@ -127,10 +112,8 @@ async function handleMcpRequest(
|
||||
}
|
||||
|
||||
// Create new session and handle this initializing request
|
||||
const { transport } = mcpService.createSession(actor);
|
||||
logger.log(
|
||||
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||
);
|
||||
const { transport } = mcpService.createSession(userId);
|
||||
logger.log(`New MCP session created for user ${userId}`);
|
||||
|
||||
await transport.handleRequest(req.raw, reply.raw, body);
|
||||
}
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { z } from 'zod';
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { Memory } from '@mosaicstack/memory';
|
||||
import type { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import type { CoordService } from '../coord/coord.service.js';
|
||||
import {
|
||||
assertMcpToolAuthorized,
|
||||
createMcpActorContext,
|
||||
deriveMcpToolScopesForUser,
|
||||
MCP_TOOL_SCOPES,
|
||||
McpService,
|
||||
type McpToolName,
|
||||
} from './mcp.service.js';
|
||||
|
||||
type ToolResult = { content: Array<{ type: 'text'; text: string }> };
|
||||
type ToolHandler = (params: Record<string, unknown>) => Promise<ToolResult>;
|
||||
|
||||
interface CapturedTool {
|
||||
inputSchema: z.ZodType;
|
||||
handler: ToolHandler;
|
||||
}
|
||||
|
||||
function makeCapturingServer(): { server: McpServer; tools: Map<string, CapturedTool> } {
|
||||
const tools = new Map<string, CapturedTool>();
|
||||
const server = {
|
||||
registerTool(name: string, config: { inputSchema: z.ZodType }, handler: ToolHandler): void {
|
||||
tools.set(name, { inputSchema: config.inputSchema, handler });
|
||||
},
|
||||
};
|
||||
return { server: server as unknown as McpServer, tools };
|
||||
}
|
||||
|
||||
function makeService(opts?: {
|
||||
projects?: Array<Record<string, unknown> & { id: string; ownerId?: string | null }>;
|
||||
missions?: Array<
|
||||
Record<string, unknown> & { id: string; projectId?: string | null; userId?: string | null }
|
||||
>;
|
||||
tasks?: Array<
|
||||
Record<string, unknown> & {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
missionId?: string | null;
|
||||
status?: string;
|
||||
}
|
||||
>;
|
||||
}) {
|
||||
const projects = opts?.projects ?? [];
|
||||
const missions = opts?.missions ?? [];
|
||||
const tasks = opts?.tasks ?? [];
|
||||
const brain = {
|
||||
projects: {
|
||||
findAll: vi.fn(async () => projects),
|
||||
findById: vi.fn(async (id: string) => projects.find((project) => project.id === id) ?? null),
|
||||
},
|
||||
tasks: {
|
||||
findAll: vi.fn(async () => tasks),
|
||||
findById: vi.fn(async (id: string) => tasks.find((task) => task.id === id) ?? null),
|
||||
findByProject: vi.fn(async (projectId: string) =>
|
||||
tasks.filter((task) => task.projectId === projectId),
|
||||
),
|
||||
findByMission: vi.fn(async (missionId: string) =>
|
||||
tasks.filter((task) => task.missionId === missionId),
|
||||
),
|
||||
findByStatus: vi.fn(async (status: string) => tasks.filter((task) => task.status === status)),
|
||||
create: vi.fn(async (task: Record<string, unknown>) => ({ id: 'task-1', ...task })),
|
||||
update: vi.fn(async (id: string, updates: Record<string, unknown>) => ({ id, ...updates })),
|
||||
},
|
||||
missions: {
|
||||
findAll: vi.fn(async () => missions),
|
||||
findById: vi.fn(async (id: string) => missions.find((mission) => mission.id === id) ?? null),
|
||||
findByProject: vi.fn(async (projectId: string) =>
|
||||
missions.filter((mission) => mission.projectId === projectId),
|
||||
),
|
||||
},
|
||||
conversations: {
|
||||
findAll: vi.fn(async (userId: string) => [{ id: 'conversation-1', userId }]),
|
||||
},
|
||||
} as unknown as Brain;
|
||||
|
||||
const memory = {
|
||||
insights: {
|
||||
searchByEmbedding: vi.fn(async (userId: string) => [{ id: 'insight-1', userId }]),
|
||||
create: vi.fn(async (insight: Record<string, unknown>) => ({ id: 'insight-2', ...insight })),
|
||||
},
|
||||
preferences: {
|
||||
findByUser: vi.fn(async (userId: string) => [{ key: 'theme', userId }]),
|
||||
findByUserAndCategory: vi.fn(async (userId: string, category: string) => [
|
||||
{ key: 'theme', userId, category },
|
||||
]),
|
||||
upsert: vi.fn(async (preference: Record<string, unknown>) => ({
|
||||
id: 'pref-1',
|
||||
...preference,
|
||||
})),
|
||||
},
|
||||
} as unknown as Memory;
|
||||
|
||||
const embeddings = {
|
||||
available: true,
|
||||
embed: vi.fn(async () => [0.1, 0.2, 0.3]),
|
||||
} as unknown as EmbeddingService;
|
||||
|
||||
const coord = {
|
||||
getMissionStatus: vi.fn(async () => null),
|
||||
listTasks: vi.fn(async () => []),
|
||||
getTaskStatus: vi.fn(async () => null),
|
||||
} as unknown as CoordService;
|
||||
|
||||
return {
|
||||
service: new McpService(brain, memory, embeddings, coord),
|
||||
brain: brain as unknown as {
|
||||
conversations: { findAll: ReturnType<typeof vi.fn> };
|
||||
tasks: {
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
},
|
||||
memory: memory as unknown as {
|
||||
insights: { searchByEmbedding: ReturnType<typeof vi.fn> };
|
||||
},
|
||||
coord: coord as unknown as {
|
||||
listTasks: ReturnType<typeof vi.fn>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getTool(tools: Map<string, CapturedTool>, name: McpToolName): CapturedTool {
|
||||
const tool = tools.get(name);
|
||||
if (!tool) throw new Error(`Missing captured tool ${name}`);
|
||||
return tool;
|
||||
}
|
||||
|
||||
function makeMemberActor(userId = 'authenticated-user') {
|
||||
return createMcpActorContext({
|
||||
userId,
|
||||
role: 'member',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
});
|
||||
}
|
||||
|
||||
function makeAdminActor(userId = 'admin-user', tenantId?: string) {
|
||||
return createMcpActorContext({
|
||||
userId,
|
||||
tenantId,
|
||||
role: 'admin',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'admin' }),
|
||||
});
|
||||
}
|
||||
|
||||
function makePlatformAdminActor(userId = 'platform-admin-user') {
|
||||
return createMcpActorContext({
|
||||
userId,
|
||||
role: 'platform-admin',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'platform-admin' }),
|
||||
});
|
||||
}
|
||||
|
||||
describe('MCP actor identity and tool scope enforcement', () => {
|
||||
it('derives immutable actor, tenant, channel, correlation, and explicit tool scopes server-side', () => {
|
||||
const actor = createMcpActorContext({
|
||||
userId: ' user-authenticated ',
|
||||
role: 'member',
|
||||
scopes: deriveMcpToolScopesForUser({ role: 'member' }),
|
||||
});
|
||||
|
||||
expect(actor.userId).toBe('user-authenticated');
|
||||
expect(actor.tenantId).toBe('user:user-authenticated');
|
||||
expect(actor.role).toBe('member');
|
||||
expect(actor.channel).toBe('mcp');
|
||||
expect(actor.correlationId).toMatch(/[0-9a-f-]{36}/i);
|
||||
expect(actor.scopes.has(MCP_TOOL_SCOPES.memory_search)).toBe(true);
|
||||
expect(actor.scopes.has(MCP_TOOL_SCOPES.coord_list_tasks)).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(false);
|
||||
expect(
|
||||
deriveMcpToolScopesForUser({ role: 'platform-admin' }).has(MCP_TOOL_SCOPES.coord_list_tasks),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when scopes are not supplied by the authenticated context policy', () => {
|
||||
const actor = createMcpActorContext({ userId: 'user-authenticated' });
|
||||
|
||||
expect(actor.scopes.size).toBe(0);
|
||||
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
|
||||
'MCP tool scope denied: memory:insight:read',
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed when a tool caller supplies actor or tenant identity fields', () => {
|
||||
const actor = createMcpActorContext({ userId: 'user-authenticated' });
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'memory_search', {
|
||||
userId: 'victim-user',
|
||||
query: 'private data',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: userId');
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', {
|
||||
tenantId: 'victim-tenant',
|
||||
projectPath: '/tmp/project',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: tenantId');
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'brain_create_task', {
|
||||
title: 'forged org',
|
||||
organizationId: 'victim-org',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: organizationId');
|
||||
|
||||
expect(() =>
|
||||
assertMcpToolAuthorized(actor, 'brain_update_task', {
|
||||
title: 'forged team',
|
||||
teamId: 'victim-team',
|
||||
}),
|
||||
).toThrow('MCP caller-controlled identity field is forbidden: teamId');
|
||||
});
|
||||
|
||||
it('fails closed when the server-derived actor lacks the required per-tool scope', () => {
|
||||
const actor = createMcpActorContext({ userId: 'user-authenticated', scopes: [] });
|
||||
|
||||
expect(() => assertMcpToolAuthorized(actor, 'memory_search', { query: 'notes' })).toThrow(
|
||||
'MCP tool scope denied: memory:insight:read',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes caller-controlled userId from memory schemas and never queries victim memory', async () => {
|
||||
const { service, memory } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeMemberActor('authenticated-user');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const tool = getTool(tools, 'memory_search');
|
||||
|
||||
expect(tool.inputSchema.safeParse({ userId: 'victim-user', query: 'anything' }).success).toBe(
|
||||
false,
|
||||
);
|
||||
await expect(tool.handler({ userId: 'victim-user', query: 'anything' })).rejects.toThrow(
|
||||
'MCP caller-controlled identity field is forbidden: userId',
|
||||
);
|
||||
expect(memory.insights.searchByEmbedding).not.toHaveBeenCalled();
|
||||
|
||||
await tool.handler({ query: 'only my notes' });
|
||||
expect(memory.insights.searchByEmbedding).toHaveBeenCalledWith(
|
||||
'authenticated-user',
|
||||
[0.1, 0.2, 0.3],
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it('binds conversation listing to the authenticated actor instead of a caller-supplied userId', async () => {
|
||||
const { service, brain } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeMemberActor('authenticated-user');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
const tool = getTool(tools, 'brain_list_conversations');
|
||||
|
||||
expect(tool.inputSchema.safeParse({ userId: 'victim-user' }).success).toBe(false);
|
||||
await expect(tool.handler({ userId: 'victim-user' })).rejects.toThrow(
|
||||
'MCP caller-controlled identity field is forbidden: userId',
|
||||
);
|
||||
expect(brain.conversations.findAll).not.toHaveBeenCalled();
|
||||
|
||||
await tool.handler({});
|
||||
expect(brain.conversations.findAll).toHaveBeenCalledWith('authenticated-user');
|
||||
});
|
||||
|
||||
it('scopes brain project, mission, and task reads to the authenticated actor', async () => {
|
||||
const { service } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-owned', ownerId: 'authenticated-user', name: 'owned' },
|
||||
{ id: 'project-victim', ownerId: 'victim-user', name: 'victim' },
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-owned', projectId: 'project-owned' },
|
||||
{ id: 'mission-victim', userId: 'victim-user', projectId: 'project-victim' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-owned-project', projectId: 'project-owned', status: 'not-started' },
|
||||
{ id: 'task-owned-mission', missionId: 'mission-owned', status: 'not-started' },
|
||||
{ id: 'task-victim-project', projectId: 'project-victim', status: 'not-started' },
|
||||
{ id: 'task-unowned', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeMemberActor('authenticated-user');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-owned']);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-owned']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual([
|
||||
'task-owned-project',
|
||||
'task-owned-mission',
|
||||
]);
|
||||
});
|
||||
|
||||
it('enforces tenant boundaries for tenant-admin brain project, mission, and task reads', async () => {
|
||||
const { service } = makeService({
|
||||
projects: [
|
||||
{
|
||||
id: 'project-tenant-a',
|
||||
ownerId: 'other-user-a',
|
||||
teamId: 'tenant-a',
|
||||
name: 'same tenant',
|
||||
},
|
||||
{
|
||||
id: 'project-tenant-b',
|
||||
ownerId: 'other-user-b',
|
||||
teamId: 'tenant-b',
|
||||
name: 'other tenant',
|
||||
},
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
const projects = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_projects').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(projects.map((project: { id: string }) => project.id)).toEqual(['project-tenant-a']);
|
||||
|
||||
const missions = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_missions').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(missions.map((mission: { id: string }) => mission.id)).toEqual(['mission-tenant-a']);
|
||||
|
||||
const tasks = JSON.parse(
|
||||
(await getTool(tools, 'brain_list_tasks').handler({})).content[0]!.text,
|
||||
);
|
||||
expect(tasks.map((task: { id: string }) => task.id)).toEqual(['task-tenant-a']);
|
||||
});
|
||||
|
||||
it('denies tenant-admin task writes outside the authenticated tenant', async () => {
|
||||
const { service, brain } = makeService({
|
||||
projects: [
|
||||
{ id: 'project-tenant-a', ownerId: 'other-user-a', teamId: 'tenant-a' },
|
||||
{ id: 'project-tenant-b', ownerId: 'other-user-b', teamId: 'tenant-b' },
|
||||
],
|
||||
missions: [
|
||||
{ id: 'mission-tenant-a', tenantId: 'tenant-a', projectId: 'project-tenant-a' },
|
||||
{ id: 'mission-tenant-b', tenantId: 'tenant-b', projectId: 'project-tenant-b' },
|
||||
],
|
||||
tasks: [
|
||||
{ id: 'task-tenant-a', projectId: 'project-tenant-a', status: 'not-started' },
|
||||
{ id: 'task-tenant-b', projectId: 'project-tenant-b', status: 'not-started' },
|
||||
],
|
||||
});
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const actor = makeAdminActor('tenant-admin-user', 'tenant-a');
|
||||
|
||||
service.registerTools(server, actor);
|
||||
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({
|
||||
title: 'unscoped tenant write',
|
||||
}),
|
||||
).rejects.toThrow('MCP task scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
getTool(tools, 'brain_create_task').handler({
|
||||
title: 'cross-tenant write',
|
||||
projectId: 'project-tenant-b',
|
||||
}),
|
||||
).rejects.toThrow('MCP task project scope denied');
|
||||
expect(brain.tasks.create).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
getTool(tools, 'brain_update_task').handler({
|
||||
id: 'task-tenant-a',
|
||||
projectId: 'project-tenant-b',
|
||||
}),
|
||||
).rejects.toThrow('MCP task project scope denied');
|
||||
expect(brain.tasks.update).not.toHaveBeenCalled();
|
||||
|
||||
const updateResult = await getTool(tools, 'brain_update_task').handler({
|
||||
id: 'task-tenant-b',
|
||||
title: 'cross-tenant update',
|
||||
});
|
||||
expect(updateResult.content[0]!.text).toBe('Task not found: task-tenant-b');
|
||||
expect(brain.tasks.update).not.toHaveBeenCalled();
|
||||
|
||||
await getTool(tools, 'brain_create_task').handler({
|
||||
title: 'same-tenant write',
|
||||
projectId: 'project-tenant-a',
|
||||
});
|
||||
expect(brain.tasks.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ projectId: 'project-tenant-a', title: 'same-tenant write' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps admin-only coordination tools on server-derived paths', async () => {
|
||||
const { service, coord } = makeService();
|
||||
const { server, tools } = makeCapturingServer();
|
||||
const member = makeMemberActor('authenticated-user');
|
||||
const tenantAdmin = makeAdminActor('admin-user');
|
||||
const platformAdmin = makePlatformAdminActor('platform-admin-user');
|
||||
|
||||
service.registerTools(server, member);
|
||||
const memberTool = getTool(tools, 'coord_list_tasks');
|
||||
expect(memberTool.inputSchema.safeParse({ projectPath: '/tmp/victim' }).success).toBe(false);
|
||||
await expect(memberTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
tools.clear();
|
||||
service.registerTools(server, tenantAdmin);
|
||||
const tenantAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await expect(tenantAdminTool.handler({})).rejects.toThrow('MCP tool scope denied: coord:read');
|
||||
|
||||
tools.clear();
|
||||
service.registerTools(server, platformAdmin);
|
||||
const platformAdminTool = getTool(tools, 'coord_list_tasks');
|
||||
await platformAdminTool.handler({ projectPath: '/tmp/victim' });
|
||||
expect(coord.listTasks).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
it('does not attach a guessed or stale-scope MCP session to another authenticated context', async () => {
|
||||
const { service } = makeService();
|
||||
const owner = makeMemberActor('owner-user');
|
||||
const attacker = makeMemberActor('attacker-user');
|
||||
const admin = makeAdminActor('admin-user');
|
||||
const downgradedAdmin = makeMemberActor('admin-user');
|
||||
|
||||
const { sessionId, transport } = service.createSession(owner);
|
||||
const staleSession = service.createSession(admin);
|
||||
|
||||
expect(service.getSession(sessionId, owner)).toBe(transport);
|
||||
expect(service.getSession(sessionId, attacker)).toBeNull();
|
||||
expect(service.getSession(sessionId, owner)).toBe(transport);
|
||||
expect(service.getSession(staleSession.sessionId, downgradedAdmin)).toBeNull();
|
||||
expect(service.getSession(staleSession.sessionId, admin)).toBe(staleSession.transport);
|
||||
|
||||
await service.onModuleDestroy();
|
||||
});
|
||||
});
|
||||
@@ -10,216 +10,11 @@ import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
|
||||
export const MCP_CALLER_IDENTITY_FIELDS = [
|
||||
'actorId',
|
||||
'actor',
|
||||
'authenticatedUserId',
|
||||
'channel',
|
||||
'organizationId',
|
||||
'ownerId',
|
||||
'sessionUserId',
|
||||
'teamId',
|
||||
'tenant',
|
||||
'tenantId',
|
||||
'user',
|
||||
'userId',
|
||||
] as const;
|
||||
|
||||
type McpCallerIdentityField = (typeof MCP_CALLER_IDENTITY_FIELDS)[number];
|
||||
|
||||
export const MCP_TOOL_SCOPES = {
|
||||
brain_list_projects: 'brain:project:read',
|
||||
brain_get_project: 'brain:project:read',
|
||||
brain_list_tasks: 'brain:task:read',
|
||||
brain_create_task: 'brain:task:write',
|
||||
brain_update_task: 'brain:task:write',
|
||||
brain_list_missions: 'brain:mission:read',
|
||||
brain_list_conversations: 'brain:conversation:read',
|
||||
memory_search: 'memory:insight:read',
|
||||
memory_get_preferences: 'memory:preference:read',
|
||||
memory_save_preference: 'memory:preference:write',
|
||||
memory_save_insight: 'memory:insight:write',
|
||||
coord_mission_status: 'coord:read',
|
||||
coord_list_tasks: 'coord:read',
|
||||
coord_task_detail: 'coord:read',
|
||||
} as const;
|
||||
|
||||
export type McpToolName = keyof typeof MCP_TOOL_SCOPES;
|
||||
export type McpToolScope = (typeof MCP_TOOL_SCOPES)[McpToolName];
|
||||
|
||||
export interface McpActorContext {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
role: string;
|
||||
channel: 'mcp';
|
||||
correlationId: string;
|
||||
scopes: ReadonlySet<McpToolScope>;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
server: McpServer;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
createdAt: Date;
|
||||
actor: McpActorContext;
|
||||
}
|
||||
|
||||
const GLOBAL_ADMIN_MCP_SCOPES = new Set<McpToolScope>(Object.values(MCP_TOOL_SCOPES));
|
||||
const TENANT_ADMIN_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_create_task,
|
||||
MCP_TOOL_SCOPES.brain_update_task,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
const MEMBER_MCP_SCOPES = new Set<McpToolScope>([
|
||||
MCP_TOOL_SCOPES.brain_list_projects,
|
||||
MCP_TOOL_SCOPES.brain_get_project,
|
||||
MCP_TOOL_SCOPES.brain_list_tasks,
|
||||
MCP_TOOL_SCOPES.brain_list_missions,
|
||||
MCP_TOOL_SCOPES.brain_list_conversations,
|
||||
MCP_TOOL_SCOPES.memory_search,
|
||||
MCP_TOOL_SCOPES.memory_get_preferences,
|
||||
MCP_TOOL_SCOPES.memory_save_preference,
|
||||
MCP_TOOL_SCOPES.memory_save_insight,
|
||||
]);
|
||||
|
||||
export function deriveMcpToolScopesForUser(input: {
|
||||
role?: string | null;
|
||||
}): ReadonlySet<McpToolScope> {
|
||||
if (input.role === 'platform-admin' || input.role === 'super-admin') {
|
||||
return new Set(GLOBAL_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
if (input.role === 'admin') {
|
||||
return new Set(TENANT_ADMIN_MCP_SCOPES);
|
||||
}
|
||||
return new Set(MEMBER_MCP_SCOPES);
|
||||
}
|
||||
|
||||
export function createMcpActorContext(input: {
|
||||
userId: string;
|
||||
tenantId?: string;
|
||||
role?: string | null;
|
||||
scopes?: Iterable<McpToolScope>;
|
||||
correlationId?: string;
|
||||
}): McpActorContext {
|
||||
const userId = input.userId.trim();
|
||||
if (userId.length === 0) {
|
||||
throw new Error('MCP authenticated user is required');
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
tenantId: input.tenantId?.trim() || `user:${userId}`,
|
||||
role: input.role ?? 'member',
|
||||
channel: 'mcp',
|
||||
correlationId: input.correlationId ?? randomUUID(),
|
||||
scopes: new Set(input.scopes ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
export function assertNoCallerControlledIdentity(params: unknown): void {
|
||||
if (params === null || typeof params !== 'object') return;
|
||||
|
||||
const keys = new Set(Object.keys(params));
|
||||
const forbidden = MCP_CALLER_IDENTITY_FIELDS.find((field: McpCallerIdentityField) =>
|
||||
keys.has(field),
|
||||
);
|
||||
if (forbidden) {
|
||||
throw new Error(`MCP caller-controlled identity field is forbidden: ${forbidden}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertMcpToolAuthorized(
|
||||
actor: McpActorContext,
|
||||
toolName: McpToolName,
|
||||
params: unknown,
|
||||
): void {
|
||||
assertNoCallerControlledIdentity(params);
|
||||
const requiredScope = MCP_TOOL_SCOPES[toolName];
|
||||
if (!actor.scopes.has(requiredScope)) {
|
||||
throw new Error(`MCP tool scope denied: ${requiredScope}`);
|
||||
}
|
||||
}
|
||||
|
||||
function strictObject<T extends z.ZodRawShape>(shape: T): z.ZodObject<T> {
|
||||
return z.object(shape).strict();
|
||||
}
|
||||
|
||||
type TenantScopedLike = {
|
||||
tenantId?: string | null;
|
||||
organizationId?: string | null;
|
||||
teamId?: string | null;
|
||||
};
|
||||
type ProjectLike = TenantScopedLike & { id: string; ownerId?: string | null };
|
||||
type MissionLike = TenantScopedLike & {
|
||||
id: string;
|
||||
projectId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
type TaskLike = TenantScopedLike & {
|
||||
projectId?: string | null;
|
||||
missionId?: string | null;
|
||||
userId?: string | null;
|
||||
};
|
||||
|
||||
function isGlobalAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'platform-admin' || actor.role === 'super-admin';
|
||||
}
|
||||
|
||||
function isTenantAdminActor(actor: McpActorContext): boolean {
|
||||
return actor.role === 'admin';
|
||||
}
|
||||
|
||||
function matchesTenant(actor: McpActorContext, record: TenantScopedLike): boolean {
|
||||
return (
|
||||
record.tenantId === actor.tenantId ||
|
||||
record.organizationId === actor.tenantId ||
|
||||
record.teamId === actor.tenantId
|
||||
);
|
||||
}
|
||||
|
||||
function filterProjectsForActor<T extends ProjectLike>(actor: McpActorContext, projects: T[]): T[] {
|
||||
if (isGlobalAdminActor(actor)) return projects;
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.ownerId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, project)),
|
||||
);
|
||||
}
|
||||
|
||||
function filterMissionsByDirectActorScope<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): T[] {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
mission.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, mission)),
|
||||
);
|
||||
}
|
||||
|
||||
function scopesEqual(left: ReadonlySet<McpToolScope>, right: ReadonlySet<McpToolScope>): boolean {
|
||||
if (left.size !== right.size) return false;
|
||||
for (const scope of left) {
|
||||
if (!right.has(scope)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function sameActorAuthorization(stored: McpActorContext, current: McpActorContext): boolean {
|
||||
return (
|
||||
stored.userId === current.userId &&
|
||||
stored.tenantId === current.tenantId &&
|
||||
stored.role === current.role &&
|
||||
scopesEqual(stored.scopes, current.scopes)
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -238,18 +33,13 @@ export class McpService implements OnModuleDestroy {
|
||||
* Creates a new MCP session with its own server + transport pair.
|
||||
* Returns the transport for use by the controller.
|
||||
*/
|
||||
createSession(actor: McpActorContext): {
|
||||
sessionId: string;
|
||||
transport: StreamableHTTPServerTransport;
|
||||
} {
|
||||
createSession(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => sessionId,
|
||||
onsessioninitialized: (id) => {
|
||||
this.logger.log(
|
||||
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||
);
|
||||
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,7 +48,7 @@ export class McpService implements OnModuleDestroy {
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
this.registerTools(server, actor);
|
||||
this.registerTools(server, userId);
|
||||
|
||||
transport.onclose = () => {
|
||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
||||
@@ -271,126 +61,31 @@ export class McpService implements OnModuleDestroy {
|
||||
);
|
||||
});
|
||||
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
|
||||
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
|
||||
return { sessionId, transport };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the transport for an existing session only when it belongs to the
|
||||
* currently authenticated MCP actor. Guessed or cross-tenant session IDs grant
|
||||
* no authority.
|
||||
* Returns the transport for an existing session, or null if not found.
|
||||
*/
|
||||
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
|
||||
const entry = this.sessions.get(sessionId);
|
||||
if (!entry) return null;
|
||||
if (!sameActorAuthorization(entry.actor, actor)) {
|
||||
this.logger.warn(
|
||||
`MCP session actor or scope mismatch: session=${sessionId} actor=${actor.userId} tenant=${actor.tenantId} role=${actor.role}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return entry.transport;
|
||||
}
|
||||
|
||||
private async isProjectAuthorized(actor: McpActorContext, projectId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const project = (await this.brain.projects.findById(projectId)) as ProjectLike | undefined;
|
||||
return project ? filterProjectsForActor(actor, [project]).length === 1 : false;
|
||||
}
|
||||
|
||||
private async filterMissionsForActor<T extends MissionLike>(
|
||||
actor: McpActorContext,
|
||||
missions: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return missions;
|
||||
|
||||
const projects = (await this.brain.projects.findAll()) as ProjectLike[];
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects).map((project) => project.id),
|
||||
);
|
||||
|
||||
return missions.filter(
|
||||
(mission) =>
|
||||
filterMissionsByDirectActorScope(actor, [mission]).length === 1 ||
|
||||
(typeof mission.projectId === 'string' && projectIds.has(mission.projectId)),
|
||||
);
|
||||
}
|
||||
|
||||
private async isMissionAuthorized(actor: McpActorContext, missionId: string): Promise<boolean> {
|
||||
if (isGlobalAdminActor(actor)) return true;
|
||||
const mission = (await this.brain.missions.findById(missionId)) as MissionLike | undefined;
|
||||
if (!mission) return false;
|
||||
return (await this.filterMissionsForActor(actor, [mission])).length === 1;
|
||||
}
|
||||
|
||||
private async assertTaskReferencesAuthorized(
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (refs.projectId && !(await this.isProjectAuthorized(actor, refs.projectId))) {
|
||||
throw new Error('MCP task project scope denied');
|
||||
}
|
||||
if (refs.missionId && !(await this.isMissionAuthorized(actor, refs.missionId))) {
|
||||
throw new Error('MCP task mission scope denied');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertTaskCreateScopeAuthorized(
|
||||
actor: McpActorContext,
|
||||
refs: { projectId?: string | null; missionId?: string | null },
|
||||
): Promise<void> {
|
||||
if (!isGlobalAdminActor(actor) && !refs.projectId && !refs.missionId) {
|
||||
throw new Error('MCP task scope denied');
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, refs);
|
||||
}
|
||||
|
||||
private async filterTasksForActor<T extends TaskLike>(
|
||||
actor: McpActorContext,
|
||||
tasks: T[],
|
||||
): Promise<T[]> {
|
||||
if (isGlobalAdminActor(actor)) return tasks;
|
||||
|
||||
const [projects, missions] = await Promise.all([
|
||||
this.brain.projects.findAll(),
|
||||
this.brain.missions.findAll(),
|
||||
]);
|
||||
const projectIds = new Set(
|
||||
filterProjectsForActor(actor, projects as ProjectLike[]).map((project) => project.id),
|
||||
);
|
||||
const missionIds = new Set(
|
||||
(await this.filterMissionsForActor(actor, missions as MissionLike[])).map(
|
||||
(mission) => mission.id,
|
||||
),
|
||||
);
|
||||
|
||||
return tasks.filter(
|
||||
(task) =>
|
||||
task.userId === actor.userId ||
|
||||
(isTenantAdminActor(actor) && matchesTenant(actor, task)) ||
|
||||
(typeof task.projectId === 'string' && projectIds.has(task.projectId)) ||
|
||||
(typeof task.missionId === 'string' && missionIds.has(task.missionId)),
|
||||
);
|
||||
getSession(sessionId: string): StreamableHTTPServerTransport | null {
|
||||
return this.sessions.get(sessionId)?.transport ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers all platform tools on the given McpServer instance.
|
||||
*/
|
||||
registerTools(server: McpServer, actor: McpActorContext): void {
|
||||
private registerTools(server: McpServer, _userId: string): void {
|
||||
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
'brain_list_projects',
|
||||
{
|
||||
description: 'List all projects in the brain.',
|
||||
inputSchema: strictObject({}),
|
||||
inputSchema: z.object({}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
|
||||
const projects = filterProjectsForActor(
|
||||
actor,
|
||||
(await this.brain.projects.findAll()) as ProjectLike[],
|
||||
);
|
||||
async () => {
|
||||
const projects = await this.brain.projects.findAll();
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||
};
|
||||
@@ -401,21 +96,17 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_get_project',
|
||||
{
|
||||
description: 'Get a project by ID.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('Project ID (UUID)'),
|
||||
}),
|
||||
},
|
||||
async ({ id, ...params }) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_get_project', params);
|
||||
const project = (await this.brain.projects.findById(id)) as ProjectLike | undefined;
|
||||
const authorizedProject = project ? filterProjectsForActor(actor, [project])[0] : undefined;
|
||||
async ({ id }) => {
|
||||
const project = await this.brain.projects.findById(id);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: authorizedProject
|
||||
? JSON.stringify(authorizedProject, null, 2)
|
||||
: `Project not found: ${id}`,
|
||||
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -428,23 +119,20 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_list_tasks',
|
||||
{
|
||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||
status: z.string().optional().describe('Filter by status'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
|
||||
const { projectId, missionId, status } = params;
|
||||
async ({ projectId, missionId, status }) => {
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
let tasks;
|
||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||
else tasks = await this.brain.tasks.findAll();
|
||||
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
|
||||
@@ -452,7 +140,7 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_create_task',
|
||||
{
|
||||
description: 'Create a new task in the brain.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
title: z.string().describe('Task title'),
|
||||
description: z.string().optional().describe('Task description'),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
@@ -461,8 +149,6 @@ export class McpService implements OnModuleDestroy {
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_create_task', params);
|
||||
await this.assertTaskCreateScopeAuthorized(actor, params);
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.create({
|
||||
...params,
|
||||
@@ -476,7 +162,7 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_update_task',
|
||||
{
|
||||
description: 'Update an existing task.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
id: z.string().describe('Task ID'),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
@@ -485,17 +171,9 @@ export class McpService implements OnModuleDestroy {
|
||||
.optional()
|
||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||
priority: z.string().optional(),
|
||||
projectId: z.string().optional().describe('Project ID'),
|
||||
missionId: z.string().optional().describe('Mission ID'),
|
||||
}),
|
||||
},
|
||||
async ({ id, ...updates }) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_update_task', updates);
|
||||
const existing = (await this.brain.tasks.findById(id)) as TaskLike | undefined;
|
||||
if (!existing || (await this.filterTasksForActor(actor, [existing])).length === 0) {
|
||||
return { content: [{ type: 'text' as const, text: `Task not found: ${id}` }] };
|
||||
}
|
||||
await this.assertTaskReferencesAuthorized(actor, updates);
|
||||
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||
const task = await this.brain.tasks.update(id, {
|
||||
@@ -520,19 +198,14 @@ export class McpService implements OnModuleDestroy {
|
||||
'brain_list_missions',
|
||||
{
|
||||
description: 'List all missions, optionally filtered by project.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
projectId: z.string().optional().describe('Filter by project ID'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
|
||||
const { projectId } = params;
|
||||
const missions = await this.filterMissionsForActor(
|
||||
actor,
|
||||
(projectId
|
||||
? await this.brain.missions.findByProject(projectId)
|
||||
: await this.brain.missions.findAll()) as MissionLike[],
|
||||
);
|
||||
async ({ projectId }) => {
|
||||
const missions = projectId
|
||||
? await this.brain.missions.findByProject(projectId)
|
||||
: await this.brain.missions.findAll();
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -540,12 +213,13 @@ export class McpService implements OnModuleDestroy {
|
||||
server.registerTool(
|
||||
'brain_list_conversations',
|
||||
{
|
||||
description: 'List conversations for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({}),
|
||||
description: 'List conversations for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
|
||||
const conversations = await this.brain.conversations.findAll(actor.userId);
|
||||
async ({ userId }) => {
|
||||
const conversations = await this.brain.conversations.findAll(userId);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||
};
|
||||
@@ -558,15 +232,14 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_search',
|
||||
{
|
||||
description:
|
||||
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
|
||||
inputSchema: strictObject({
|
||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID to search memory for'),
|
||||
query: z.string().describe('Natural language search query'),
|
||||
limit: z.number().optional().describe('Max results (default 5)'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_search', params);
|
||||
const { query, limit } = params;
|
||||
async ({ userId, query, limit }) => {
|
||||
if (!this.embeddings.available) {
|
||||
return {
|
||||
content: [
|
||||
@@ -578,11 +251,7 @@ export class McpService implements OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
const embedding = await this.embeddings.embed(query);
|
||||
const results = await this.memory.insights.searchByEmbedding(
|
||||
actor.userId,
|
||||
embedding,
|
||||
limit ?? 5,
|
||||
);
|
||||
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -590,21 +259,20 @@ export class McpService implements OnModuleDestroy {
|
||||
server.registerTool(
|
||||
'memory_get_preferences',
|
||||
{
|
||||
description: 'Retrieve stored preferences for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({
|
||||
description: 'Retrieve stored preferences for a user.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
category: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
|
||||
const { category } = params;
|
||||
async ({ userId, category }) => {
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
const prefs = category
|
||||
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(actor.userId);
|
||||
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
||||
: await this.memory.preferences.findByUser(userId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -613,8 +281,9 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_save_preference',
|
||||
{
|
||||
description:
|
||||
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
|
||||
inputSchema: strictObject({
|
||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
key: z.string().describe('Preference key'),
|
||||
value: z.string().describe('Preference value (JSON string)'),
|
||||
category: z
|
||||
@@ -623,9 +292,7 @@ export class McpService implements OnModuleDestroy {
|
||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
|
||||
const { key, value, category } = params;
|
||||
async ({ userId, key, value, category }) => {
|
||||
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||
let parsedValue: unknown;
|
||||
try {
|
||||
@@ -634,7 +301,7 @@ export class McpService implements OnModuleDestroy {
|
||||
parsedValue = value;
|
||||
}
|
||||
const pref = await this.memory.preferences.upsert({
|
||||
userId: actor.userId,
|
||||
userId,
|
||||
key,
|
||||
value: parsedValue,
|
||||
category: (category as Cat) ?? 'general',
|
||||
@@ -648,8 +315,9 @@ export class McpService implements OnModuleDestroy {
|
||||
'memory_save_insight',
|
||||
{
|
||||
description:
|
||||
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
|
||||
inputSchema: strictObject({
|
||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
||||
inputSchema: z.object({
|
||||
userId: z.string().describe('User ID'),
|
||||
content: z.string().describe('The insight or knowledge to store'),
|
||||
category: z
|
||||
.string()
|
||||
@@ -657,13 +325,11 @@ export class McpService implements OnModuleDestroy {
|
||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
|
||||
const { content, category } = params;
|
||||
async ({ userId, content, category }) => {
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||
const insight = await this.memory.insights.create({
|
||||
userId: actor.userId,
|
||||
userId,
|
||||
content,
|
||||
embedding,
|
||||
source: 'agent',
|
||||
@@ -680,11 +346,16 @@ export class McpService implements OnModuleDestroy {
|
||||
{
|
||||
description:
|
||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||
inputSchema: strictObject({}),
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
|
||||
const status = await this.coordService.getMissionStatus(process.cwd());
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -700,11 +371,16 @@ export class McpService implements OnModuleDestroy {
|
||||
'coord_list_tasks',
|
||||
{
|
||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||
inputSchema: strictObject({}),
|
||||
inputSchema: z.object({
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
|
||||
const tasks = await this.coordService.listTasks(process.cwd());
|
||||
async ({ projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const tasks = await this.coordService.listTasks(resolvedPath);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||
},
|
||||
);
|
||||
@@ -713,14 +389,17 @@ export class McpService implements OnModuleDestroy {
|
||||
'coord_task_detail',
|
||||
{
|
||||
description: 'Get detailed status for a specific orchestration task.',
|
||||
inputSchema: strictObject({
|
||||
inputSchema: z.object({
|
||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
||||
projectPath: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Project path. Defaults to gateway working directory.'),
|
||||
}),
|
||||
},
|
||||
async (params) => {
|
||||
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
|
||||
const { taskId } = params;
|
||||
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
|
||||
async ({ taskId, projectPath }) => {
|
||||
const resolvedPath = projectPath ?? process.cwd();
|
||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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,41 +50,19 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
||||
|
||||
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[] {
|
||||
const plugins: IChannelPlugin[] = [];
|
||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||
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 (!discordServiceToken || !discordServiceUserId) {
|
||||
throw new Error(
|
||||
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
|
||||
);
|
||||
}
|
||||
plugins.push(
|
||||
new DiscordChannelPluginAdapter(
|
||||
new DiscordPlugin({
|
||||
token: discordToken,
|
||||
guildId: discordGuildId,
|
||||
gatewayUrl: discordGatewayUrl,
|
||||
serviceToken: discordServiceToken,
|
||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||
|
||||
const scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string, scope: ActorTenantScope) =>
|
||||
`mosaic:session:${scopedSessionId(sessionId, scope)}:system:fragments`;
|
||||
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
|
||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
||||
`mosaic:session:${sessionId}:system:fragments`;
|
||||
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||
|
||||
interface OverrideFragment {
|
||||
@@ -24,9 +20,9 @@ export class SystemOverrideService {
|
||||
this.handle = createQueue();
|
||||
}
|
||||
|
||||
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||
async set(sessionId: string, override: string): Promise<void> {
|
||||
// Load existing fragments
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
|
||||
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
|
||||
const fragments: OverrideFragment[] = existing
|
||||
? (JSON.parse(existing) as OverrideFragment[])
|
||||
: [];
|
||||
@@ -41,11 +37,11 @@ export class SystemOverrideService {
|
||||
// Store both: fragments array and condensed result
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.setex(
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||
JSON.stringify(fragments),
|
||||
);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
|
||||
await pipeline.exec();
|
||||
|
||||
this.logger.debug(
|
||||
@@ -53,21 +49,21 @@ export class SystemOverrideService {
|
||||
);
|
||||
}
|
||||
|
||||
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
|
||||
async get(sessionId: string): Promise<string | null> {
|
||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
||||
}
|
||||
|
||||
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
async renew(sessionId: string): Promise<void> {
|
||||
const pipeline = this.handle.redis.pipeline();
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||
async clear(sessionId: string): Promise<void> {
|
||||
await this.handle.redis.del(
|
||||
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||
SESSION_SYSTEM_KEY(sessionId),
|
||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
||||
);
|
||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||
}
|
||||
|
||||
@@ -232,10 +232,6 @@ 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.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -293,24 +293,13 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
||||
|
||||
### Plugins
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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_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_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.
|
||||
| Variable | Description |
|
||||
| ---------------------- | -------------------------------------------------------------------------- |
|
||||
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
||||
|
||||
### Observability
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
# #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
|
||||
@@ -1,35 +0,0 @@
|
||||
# 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.
|
||||
@@ -240,33 +240,6 @@ get_gitea_login_for_host() {
|
||||
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() {
|
||||
local logins_json
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ TITLE=""
|
||||
BODY=""
|
||||
LABELS=""
|
||||
MILESTONE=""
|
||||
INTERACTIVE=false
|
||||
|
||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||
|
||||
@@ -67,13 +66,11 @@ Options:
|
||||
-b, --body BODY Issue body/description
|
||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||
-m, --milestone NAME Milestone name to assign
|
||||
-i, --interactive Prompt for missing issue fields
|
||||
-h, --help Show this help message
|
||||
|
||||
Examples:
|
||||
$(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") -i
|
||||
EOF
|
||||
exit "${1:-1}"
|
||||
}
|
||||
@@ -97,10 +94,6 @@ while [[ $# -gt 0 ]]; do
|
||||
MILESTONE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-i|--interactive)
|
||||
INTERACTIVE=true
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage 0
|
||||
;;
|
||||
@@ -111,13 +104,6 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
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
|
||||
echo "Error: Title is required (-t)" >&2
|
||||
usage
|
||||
@@ -141,11 +127,6 @@ case "$PLATFORM" in
|
||||
gitea_issue_create_api
|
||||
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")
|
||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
|
||||
@@ -183,11 +183,6 @@ case "$PLATFORM" in
|
||||
gitea_pr_create_api
|
||||
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")
|
||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||
|
||||
@@ -45,11 +45,6 @@ JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
printf '%s\n' '{"login":"ci-bot"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||
echo 'GetUserByName: simulated stale login failure' >&2
|
||||
|
||||
@@ -55,11 +55,6 @@ JSON
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "api" ]]; then
|
||||
printf '%s\n' '{"login":"ci-bot"}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||
desc=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
#!/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,44 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeCapability,
|
||||
RuntimeError,
|
||||
RuntimeSession,
|
||||
RuntimeStreamEvent,
|
||||
} from './agent-runtime-provider.js';
|
||||
|
||||
describe('AgentRuntimeProvider contract', (): void => {
|
||||
it('exposes stable, normalized runtime-only contracts', (): void => {
|
||||
const capability: RuntimeCapability = 'session.attach';
|
||||
const session: RuntimeSession = {
|
||||
id: 'session-1',
|
||||
providerId: 'fleet',
|
||||
runtimeId: 'tmux',
|
||||
state: 'active',
|
||||
createdAt: '2026-07-12T00:00:00.000Z',
|
||||
updatedAt: '2026-07-12T00:00:00.000Z',
|
||||
};
|
||||
const event: RuntimeStreamEvent = {
|
||||
type: 'message.delta',
|
||||
sessionId: session.id,
|
||||
cursor: '2',
|
||||
occurredAt: session.updatedAt,
|
||||
content: 'hello',
|
||||
};
|
||||
const error: RuntimeError = {
|
||||
code: 'capability_unsupported',
|
||||
message: 'Denied',
|
||||
retryable: false,
|
||||
};
|
||||
const attach: RuntimeAttachHandle = {
|
||||
attachmentId: 'attach-1',
|
||||
sessionId: session.id,
|
||||
mode: 'read',
|
||||
expiresAt: session.updatedAt,
|
||||
};
|
||||
const provider: Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'> =
|
||||
{} as Pick<AgentRuntimeProvider, 'capabilities' | 'getSessionTree' | 'attach'>;
|
||||
expect([capability, session.id, event.type, error.code, attach.mode, provider]).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
export type RuntimeCapability =
|
||||
| 'session.list'
|
||||
| 'session.tree'
|
||||
| 'session.stream'
|
||||
| 'session.send'
|
||||
| 'session.attach'
|
||||
| 'session.terminate';
|
||||
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
||||
export type RuntimeAttachMode = 'read' | 'control';
|
||||
|
||||
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
||||
export interface RuntimeScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly channelId: string;
|
||||
readonly correlationId: string;
|
||||
}
|
||||
export interface RuntimeSession {
|
||||
id: string;
|
||||
providerId: string;
|
||||
runtimeId: string;
|
||||
parentSessionId?: string;
|
||||
state: RuntimeSessionState;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
export interface RuntimeSessionTree {
|
||||
session: RuntimeSession;
|
||||
children: RuntimeSessionTree[];
|
||||
}
|
||||
export interface RuntimeCapabilitySet {
|
||||
supported: RuntimeCapability[];
|
||||
}
|
||||
export interface RuntimeHealth {
|
||||
status: 'healthy' | 'degraded' | 'down';
|
||||
checkedAt: string;
|
||||
detail?: string;
|
||||
}
|
||||
export interface RuntimeMessage {
|
||||
content: string;
|
||||
idempotencyKey: string;
|
||||
}
|
||||
export interface RuntimeAttachHandle {
|
||||
attachmentId: string;
|
||||
sessionId: string;
|
||||
mode: RuntimeAttachMode;
|
||||
expiresAt: string;
|
||||
}
|
||||
export interface RuntimeError {
|
||||
code:
|
||||
| 'capability_unsupported'
|
||||
| 'not_found'
|
||||
| 'forbidden'
|
||||
| 'conflict'
|
||||
| 'unavailable'
|
||||
| 'invalid_request';
|
||||
message: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
export type RuntimeStreamEvent =
|
||||
| {
|
||||
type: 'session.state';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
state: RuntimeSessionState;
|
||||
}
|
||||
| {
|
||||
type: 'message.delta';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
type: 'message.complete';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
messageId: string;
|
||||
}
|
||||
| {
|
||||
type: 'runtime.error';
|
||||
sessionId: string;
|
||||
cursor: string;
|
||||
occurredAt: string;
|
||||
error: RuntimeError;
|
||||
};
|
||||
|
||||
/** Runtime-neutral boundary; implementations fail closed for unsupported operations. */
|
||||
export interface AgentRuntimeProvider {
|
||||
readonly id: string;
|
||||
capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet>;
|
||||
health(scope: RuntimeScope): Promise<RuntimeHealth>;
|
||||
listSessions(scope: RuntimeScope): Promise<RuntimeSession[]>;
|
||||
getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]>;
|
||||
streamSession(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
sendMessage(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
||||
attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle>;
|
||||
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
|
||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
||||
}
|
||||
@@ -2,5 +2,3 @@
|
||||
export interface AgentSessionHandle {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export * from './agent-runtime-provider.js';
|
||||
|
||||
@@ -1,109 +1,24 @@
|
||||
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
|
||||
export interface DiscordPluginConfig {
|
||||
token: string;
|
||||
gatewayUrl: string;
|
||||
/** Shared service credential injected by the approved secret mechanism. */
|
||||
serviceToken: string;
|
||||
/** Which guild to bind to (single-guild only for v0.1.0). */
|
||||
/** Which guild to bind to (single-guild only for v0.1.0) */
|
||||
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 {
|
||||
private client: Client;
|
||||
private socket: Socket | null = null;
|
||||
/** Map Discord channel ID → Mosaic conversation ID. */
|
||||
private config: DiscordPluginConfig;
|
||||
/** Map Discord channel ID → Mosaic conversation ID */
|
||||
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>();
|
||||
|
||||
constructor(private readonly config: DiscordPluginConfig) {
|
||||
constructor(config: DiscordPluginConfig) {
|
||||
this.config = config;
|
||||
this.client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
@@ -115,8 +30,8 @@ export class DiscordPlugin {
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
// Connect to gateway WebSocket
|
||||
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||
auth: { discordServiceToken: this.config.serviceToken },
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
||||
@@ -133,6 +48,7 @@ export class DiscordPlugin {
|
||||
console.error(`[discord] Gateway connection error: ${err.message}`);
|
||||
});
|
||||
|
||||
// Handle streaming text from gateway
|
||||
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||
const pending = this.pendingResponses.get(data.conversationId);
|
||||
if (pending !== undefined) {
|
||||
@@ -140,11 +56,12 @@ export class DiscordPlugin {
|
||||
}
|
||||
});
|
||||
|
||||
// When agent finishes, send the accumulated response
|
||||
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||
const text = this.pendingResponses.get(data.conversationId);
|
||||
if (text) {
|
||||
this.pendingResponses.delete(data.conversationId);
|
||||
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
|
||||
this.sendToDiscord(data.conversationId, text).catch((err) => {
|
||||
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
||||
});
|
||||
}
|
||||
@@ -154,9 +71,8 @@ export class DiscordPlugin {
|
||||
this.pendingResponses.set(data.conversationId, '');
|
||||
});
|
||||
|
||||
this.client.on('messageCreate', (message: DiscordMessage) =>
|
||||
this.handleDiscordMessage(message),
|
||||
);
|
||||
// Set up Discord message handler
|
||||
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
|
||||
|
||||
this.client.on('ready', () => {
|
||||
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
||||
@@ -180,6 +96,7 @@ export class DiscordPlugin {
|
||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||
if (!guild) return null;
|
||||
|
||||
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
|
||||
const channelName = `mosaic-${project.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
@@ -191,58 +108,58 @@ export class DiscordPlugin {
|
||||
topic: project.description ?? `Mosaic project: ${project.name}`,
|
||||
});
|
||||
|
||||
// Register the channel mapping so messages route correctly
|
||||
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
||||
|
||||
return { channelId: channel.id };
|
||||
}
|
||||
|
||||
private handleDiscordMessage(message: DiscordMessage): void {
|
||||
if (message.author.bot || !this.client.user) return;
|
||||
if (!message.guildId || !this.isAllowedMessage(message)) return;
|
||||
// Ignore bot messages
|
||||
if (message.author.bot) 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);
|
||||
if (!isMention) return;
|
||||
|
||||
if (!isDM && !isMention) return;
|
||||
|
||||
// Strip bot mention from message content
|
||||
const content = message.content
|
||||
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
||||
.trim();
|
||||
|
||||
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) {
|
||||
console.error(
|
||||
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
|
||||
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
);
|
||||
this.socket.emit('message', {
|
||||
conversationId,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
||||
// Find the Discord channel for this conversation
|
||||
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||
([, convId]) => convId === conversationId,
|
||||
)?.[0];
|
||||
@@ -260,10 +177,12 @@ export class DiscordPlugin {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const chunk of this.chunkText(text, 1900)) {
|
||||
// Chunk responses for Discord's 2000-char limit
|
||||
const chunks = this.chunkText(text, 1900);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||
} catch (err: unknown) {
|
||||
} catch (err) {
|
||||
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
||||
}
|
||||
}
|
||||
@@ -274,16 +193,21 @@ export class DiscordPlugin {
|
||||
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
if (remaining.length <= maxLength) {
|
||||
chunks.push(remaining);
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to break at a newline
|
||||
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||
if (breakPoint <= 0) breakPoint = maxLength;
|
||||
|
||||
chunks.push(remaining.slice(0, breakPoint));
|
||||
remaining = remaining.slice(breakPoint).trimStart();
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user