Compare commits

..

22 Commits

Author SHA1 Message Date
Jarvis
55ae77b6fd feat(#707): secure Discord service ingress
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
2026-07-12 18:00:29 -05:00
227b73fcdf fix(security): enforce Tess MCP server identity (#717)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-12 22:49:00 +00:00
a959b1d6b4 ci: restrict push-event CI to protected branches (halve feature-branch load) (#721)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:48:58 +00:00
62f8177806 feat(tess): define runtime provider contract (#719)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-12 22:29:21 +00:00
353e43c947 fix: enforce Tess session ownership scope (#715)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-12 22:14:17 +00:00
ca9c2b5c23 restore Tess markdown formatting gate (v2, non-author) (#714)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
2026-07-12 21:32:16 +00:00
b580d37d51 Fixes #703 (#705)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 20:49:43 +00:00
59e49cfd15 docs(tess): define Pi-native interaction agent mission (#712)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-12 18:09:54 +00:00
a99aded26d fix(tools/git): -h/--help now exits 0 across 7 wrappers (#702)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-11 09:23:46 +00:00
4df38f7e81 fix(tools/_lib): /etc/mosaic host-level fallback for credential resolution (#700)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-10 01:57:12 +00:00
4e9e053800 fix(tools/tmux): unique per-invocation paste buffer; track auto-submit-drafts.sh (#697)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-09 17:37:40 +00:00
851c67c27b docs(bootstrap): add python-is-python3 to agent-host prerequisites (#694)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-03 09:38:40 +00:00
86e106fcc9 feat(#462): add federation list verb (#682)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-25 02:15:17 +00:00
67135d3822 fix(fleet): guard mosaic fleet restart against tight-loop re-entry race (#680)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-25 01:44:48 +00:00
adb153428b feat(installer): --dev flag builds CLI + gateway from source (#681)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:54:52 +00:00
c739256a2c feat(#462): add federation scope enforcement service (#672)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 23:22:46 +00:00
fc2970916f style(federation): re-run prettier on TASKS.md (unblock format:check) (#679)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-06-24 22:51:22 +00:00
79eae2ffce fix(fleet): raise fleet-personas.spec timeout to 30s (mirror #665) (#677)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 22:16:49 +00:00
7035cd23bf docs(federation): record M3-07/09 merges + fix M3-11 dependency DAG (#678)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 21:44:57 +00:00
6b94d014a8 feat(#462): add federation capabilities verb (#674)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 21:39:56 +00:00
838c44086c feat(#462): add federation query source routing (#673)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-24 21:39:45 +00:00
3eeed04e17 docs(federation): sync M3 backlog to origin/main reality (#671)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-24 21:15:14 +00:00
84 changed files with 6749 additions and 364 deletions

View File

@@ -5,3 +5,5 @@ pnpm-lock.yaml
**/drizzle
**/.next
.claude/
docs/tess/TASKS.md
docs/scratchpads/

View File

@@ -7,7 +7,14 @@ variables:
- &enable_pnpm 'corepack enable'
when:
- event: [push, pull_request, manual]
# 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
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).

View File

@@ -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.listSessions();
const sessions = this.agentService.listAllSessionsForSystem();
const providers = this.providerService.listProviders();
const allOk = database.status === 'ok' && cache.status === 'ok';

View File

@@ -0,0 +1,142 @@
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);
});
});

View File

@@ -0,0 +1,262 @@
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 }),
);
});
});

View File

@@ -1,4 +1,11 @@
import { Inject, Injectable, Logger, Optional, type OnModuleDestroy } from '@nestjs/common';
import {
ForbiddenException,
Inject,
Injectable,
Logger,
Optional,
type OnModuleDestroy,
} from '@nestjs/common';
import {
createAgentSession,
DefaultResourceLoader,
@@ -28,6 +35,7 @@ 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 {
@@ -68,6 +76,8 @@ 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
@@ -94,6 +104,8 @@ 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). */
@@ -174,12 +186,20 @@ export class AgentService implements OnModuleDestroy {
.filter((t) => t.length > 0);
}
async createSession(sessionId: string, options?: AgentSessionOptions): Promise<AgentSession> {
async createSession(sessionId: string, options: AgentSessionOptions): Promise<AgentSession> {
const scope = this.scopeFromOptions(options);
const existing = this.sessions.get(sessionId);
if (existing) return existing;
if (existing) {
this.assertSessionScope(existing, scope);
return existing;
}
const inflight = this.creating.get(sessionId);
if (inflight) return inflight;
if (inflight) {
const session = await inflight;
this.assertSessionScope(session, scope);
return session;
}
const promise = this.doCreateSession(sessionId, options).finally(() => {
this.creating.delete(sessionId);
@@ -342,6 +362,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir,
allowedTools,
userId: mergedOptions?.userId,
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName,
metrics: {
@@ -473,38 +494,70 @@ export class AgentService implements OnModuleDestroy {
return this.providerService.getDefaultModel() ?? null;
}
getSession(sessionId: string): AgentSession | undefined {
return this.sessions.get(sessionId);
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
const session = this.sessions.get(sessionId);
if (!session || !this.sessionMatchesScope(session, scope)) return undefined;
return session;
}
listSessions(): SessionInfoDto[] {
listSessions(scope: ActorTenantScope): SessionInfoDto[] {
const now = Date.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 },
}));
return Array.from(this.sessions.values())
.filter((s) => this.sessionMatchesScope(s, scope))
.map((s) => this.toSessionInfo(s, now));
}
getSessionInfo(sessionId: string): SessionInfoDto | undefined {
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 {
const s = this.sessions.get(sessionId);
if (!s) return undefined;
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');
}
return {
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 },
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 },
};
}
@@ -553,9 +606,10 @@ 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): void {
updateSessionModel(sessionId: string, modelId: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (!session) return;
this.assertSessionScope(session, scope);
const prev = session.modelId;
session.modelId = modelId;
this.recordModelSwitch(sessionId);
@@ -572,48 +626,51 @@ 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);
this.updateSessionModel(sessionId, modelId, scope);
}
this.logger.log(
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
);
}
addChannel(sessionId: string, channel: string): void {
addChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.add(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.add(channel);
}
removeChannel(sessionId: string, channel: string): void {
removeChannel(sessionId: string, channel: string, scope: ActorTenantScope): void {
const session = this.sessions.get(sessionId);
if (session) {
session.channels.delete(channel);
}
if (!session) return;
this.assertSessionScope(session, scope);
session.channels.delete(channel);
}
async prompt(sessionId: string, message: string): Promise<void> {
async prompt(sessionId: string, message: string, scope: ActorTenantScope): 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);
const override = await this.systemOverride.get(sessionId, scope);
if (override) {
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
await this.systemOverride.renew(sessionId);
await this.systemOverride.renew(sessionId, scope);
this.logger.debug(`Applied system override for session ${sessionId}`);
}
}
@@ -629,16 +686,28 @@ export class AgentService implements OnModuleDestroy {
}
}
onEvent(sessionId: string, listener: (event: AgentSessionEvent) => void): () => void {
onEvent(
sessionId: string,
listener: (event: AgentSessionEvent) => void,
scope: ActorTenantScope,
): () => 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): Promise<void> {
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> {
const session = this.sessions.get(sessionId);
if (!session) return;
this.logger.log(`Destroying agent session ${sessionId}`);
@@ -667,7 +736,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.destroySession(id));
const stops = Array.from(this.sessions.keys()).map((id) => this.destroySessionForSystem(id));
const results = await Promise.allSettled(stops);
for (const result of results) {
if (result.status === 'rejected') {

View File

@@ -10,6 +10,8 @@ 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')
@@ -18,23 +20,24 @@ export class SessionsController {
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
@Get()
list() {
const sessions = this.agentService.listSessions();
list(@CurrentUser() user: AuthenticatedUserLike) {
const sessions = this.agentService.listSessions(scopeFromUser(user));
return { sessions, total: sessions.length };
}
@Get(':id')
findOne(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
if (!info) throw new NotFoundException('Session not found');
return info;
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async destroy(@Param('id') id: string) {
const info = this.agentService.getSessionInfo(id);
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
const scope = scopeFromUser(user);
const info = this.agentService.getSessionInfo(id, scope);
if (!info) throw new NotFoundException('Session not found');
await this.agentService.destroySession(id);
await this.agentService.destroySession(id, scope);
}
}

View File

@@ -0,0 +1,24 @@
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,
};
}

View File

@@ -12,7 +12,8 @@ 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: { id: string }');
expect(source).toContain('@CurrentUser() user: AuthenticatedUserLike');
expect(source).toContain('const scope = scopeFromUser(user);');
});
});

View File

@@ -3,8 +3,10 @@ import {
Post,
Body,
Logger,
ForbiddenException,
HttpException,
HttpStatus,
NotFoundException,
Inject,
UseGuards,
} from '@nestjs/common';
@@ -13,6 +15,7 @@ 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';
@@ -32,16 +35,23 @@ export class ChatController {
@Throttle({ default: { limit: 10, ttl: 60_000 } })
async chat(
@Body() body: ChatRequestDto,
@CurrentUser() user: { id: string },
@CurrentUser() user: AuthenticatedUserLike,
): Promise<ChatResponse> {
const conversationId = body.conversationId ?? uuid();
const scope = scopeFromUser(user);
try {
let agentSession = this.agentService.getSession(conversationId);
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
agentSession = await this.agentService.createSession(conversationId);
agentSession = await this.agentService.createSession(conversationId, {
userId: scope.userId,
tenantId: scope.tenantId,
});
}
} 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),
@@ -60,20 +70,27 @@ 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();
}
});
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,
);
});
try {
await this.agentService.prompt(conversationId, body.content);
await this.agentService.prompt(conversationId, body.content, scope);
await done;
} catch (err) {
if (err instanceof HttpException) throw err;

View File

@@ -1,3 +1,4 @@
import { timingSafeEqual } from 'node:crypto';
import type { IncomingHttpHeaders } from 'node:http';
import { fromNodeHeaders } from 'better-auth/node';
@@ -12,6 +13,19 @@ export interface SessionAuth {
};
}
export function validateDiscordServiceToken(
candidate: unknown,
expected: string | undefined,
): boolean {
if (typeof candidate !== 'string' || !expected) return false;
const candidateBuffer = Buffer.from(candidate);
const expectedBuffer = Buffer.from(expected);
return (
candidateBuffer.length === expectedBuffer.length &&
timingSafeEqual(candidateBuffer, expectedBuffer)
);
}
export async function validateSocketSession(
headers: IncomingHttpHeaders,
auth: SessionAuth,

View File

@@ -11,6 +11,11 @@ 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 {
@@ -22,13 +27,19 @@ 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 { validateSocketSession } from './chat.gateway-auth.js';
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
/** Per-client state tracking streaming accumulation for persistence. */
interface ClientSession {
@@ -40,6 +51,8 @@ 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;
}
@@ -50,6 +63,37 @@ 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',
@@ -62,6 +106,7 @@ 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,
@@ -77,6 +122,13 @@ 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}`);
@@ -87,8 +139,6 @@ 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() });
}
@@ -97,25 +147,80 @@ 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}`);
this.agentService.removeChannel(
session.conversationId,
`websocket:${client.id}`,
session.scope,
);
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() data: ChatSocketMessageDto,
@MessageBody() rawData: unknown,
): 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 userId = (client.data.user as { id: string } | undefined)?.id;
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
if (discordIngress && !discordServiceUserId) {
this.logger.warn(
`Rejected Discord ingress without configured service owner from ${client.id}`,
);
return;
}
const 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;
this.logger.log(`Message from ${client.id} in conversation ${conversationId}`);
this.logger.log(
`Message from ${client.id} in conversation ${conversationId}${correlationId ? ` correlation=${correlationId}` : ''}`,
);
// Ensure agent session exists for this conversation
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
try {
let agentSession = this.agentService.getSession(conversationId);
let agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) {
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
@@ -135,7 +240,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
let resolvedProvider = data.provider;
let resolvedModelId = data.modelId;
const modelOverride = modelOverrides.get(conversationId);
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
if (modelOverride) {
// /model override bypasses routing engine (M4-007)
resolvedModelId = modelOverride;
@@ -172,6 +277,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
modelId: resolvedModelId,
agentConfigId: data.agentId,
userId,
tenantId: scope.tenantId,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
});
@@ -213,6 +319,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
content: data.content,
metadata: {
timestamp: new Date().toISOString(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
},
},
userId,
@@ -232,9 +345,13 @@ 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);
});
const cleanup = this.agentService.onEvent(
conversationId,
(event: AgentSessionEvent) => {
this.relayEvent(client, conversationId, event);
},
scope,
);
// Preserve routing decision from the existing client session if we didn't get a new one
const prevClientSession = this.clientSessions.get(client.id);
@@ -246,16 +363,17 @@ 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}`);
this.agentService.addChannel(conversationId, `websocket:${client.id}`, scope);
// 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);
const agentSession = this.agentService.getSession(conversationId, scope);
if (agentSession) {
const piSession = agentSession.piSession;
client.emit('session:info', {
@@ -271,11 +389,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
}
// Send acknowledgment
client.emit('message:ack', { conversationId, messageId: uuid() });
client.emit('message:ack', {
conversationId,
messageId: uuid(),
...(correlationId
? {
correlationId,
discordMessageId: discordIngress?.messageId,
discordUserId: discordIngress?.userId,
}
: {}),
});
// Dispatch to agent
try {
await this.agentService.prompt(conversationId, data.content);
await this.agentService.prompt(conversationId, data.content, scope);
} catch (err) {
this.logger.error(
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
@@ -293,7 +421,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@ConnectedSocket() client: Socket,
@MessageBody() data: SetThinkingPayload,
): void {
const session = this.agentService.getSession(data.conversationId);
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);
if (!session) {
client.emit('error', {
conversationId: data.conversationId,
@@ -334,7 +471,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
const conversationId = data.conversationId;
this.logger.log(`Abort requested by ${client.id} for conversation ${conversationId}`);
const session = this.agentService.getSession(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);
if (!session) {
client.emit('error', {
conversationId,
@@ -363,8 +506,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@ConnectedSocket() client: Socket,
@MessageBody() payload: SlashCommandPayload,
): Promise<void> {
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
const result = await this.commandExecutor.execute(payload, userId);
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);
client.emit('command:result', result);
}
@@ -380,18 +533,23 @@ 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): void {
setModelOverride(
conversationId: string,
modelName: string | null,
scope: ActorTenantScope,
): void {
const key = this.modelOverrideKey(conversationId, scope);
if (modelName) {
modelOverrides.set(conversationId, modelName);
modelOverrides.set(key, 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);
this.agentService.updateSessionModel(conversationId, modelName, scope);
// M5-005: Broadcast session:info to all clients subscribed to this conversation
this.broadcastSessionInfo(conversationId);
this.broadcastSessionInfo(conversationId, scope);
} else {
modelOverrides.delete(conversationId);
modelOverrides.delete(key);
this.logger.log(`Model override cleared: conversation=${conversationId}`);
}
}
@@ -399,8 +557,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
/**
* Return the active model override for a conversation, or undefined if none.
*/
getModelOverride(conversationId: string): string | undefined {
return modelOverrides.get(conversationId);
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
}
/**
@@ -409,9 +567,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
*/
broadcastSessionInfo(
conversationId: string,
scope: ActorTenantScope,
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
): void {
const agentSession = this.agentService.getSession(conversationId);
const agentSession = this.agentService.getSession(conversationId, scope);
if (!agentSession) return;
const piSession = agentSession.piSession;
@@ -428,7 +587,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) {
if (session.conversationId === conversationId && this.scopesEqual(session.scope, scope)) {
const socket = this.server.sockets.sockets.get(clientId);
if (socket?.connected) {
socket.emit('session:info', payload);
@@ -442,6 +601,39 @@ 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);
@@ -550,7 +742,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
case 'agent_end': {
// Gather usage stats from the Pi session
const agentSession = this.agentService.getSession(conversationId);
const activeClientSession = this.clientSessions.get(client.id);
const agentSession = activeClientSession
? this.agentService.getSession(conversationId, activeClientSession.scope)
: undefined;
const piSession = agentSession?.piSession;
const stats = piSession?.getSessionStats();
const contextUsage = piSession?.getContextUsage();

View File

@@ -89,6 +89,7 @@ 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(() => {
@@ -99,7 +100,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider login');
expect(result.command).toBe('provider');
@@ -112,7 +113,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'login anthropic',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
expect(result.message).toContain('anthropic');
@@ -138,7 +139,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /provider');
});
@@ -146,7 +147,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('provider');
});
@@ -154,7 +155,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Usage: /provider logout');
});
@@ -166,7 +167,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'unknown',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('Unknown subcommand');
});
@@ -174,7 +175,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('mission');
expect(result.message).toContain('Mission status');
@@ -183,7 +184,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Mission status');
});
@@ -195,7 +196,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'set my-mission-123',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-mission-123');
});
@@ -203,7 +204,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('agent');
expect(result.message).toContain('agent');
@@ -212,7 +213,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage: /agent');
});
@@ -224,7 +225,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
args: 'my-agent-id',
conversationId,
};
const result = await service.execute(payload, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('my-agent-id');
});
@@ -232,7 +233,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('prdy');
expect(result.message).toContain('mosaic prdy');
@@ -241,7 +242,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, userId);
const result = await service.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('tools');
expect(result.message).toContain('tools');

View File

@@ -3,6 +3,7 @@ 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';
@@ -34,8 +35,12 @@ export class CommandExecutorService {
private readonly mcpClient: McpClientService | null,
) {}
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
async execute(
payload: SlashCommandPayload,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const { command, args, conversationId } = payload;
const userId = scope.userId;
const def = this.registry.getManifest().commands.find((c) => c.name === command);
if (!def) {
@@ -50,11 +55,11 @@ export class CommandExecutorService {
try {
switch (command) {
case 'model':
return await this.handleModel(args ?? null, conversationId);
return await this.handleModel(args ?? null, conversationId, scope);
case 'thinking':
return await this.handleThinking(args ?? null, conversationId);
case 'system':
return await this.handleSystem(args ?? null, conversationId);
return await this.handleSystem(args ?? null, conversationId, scope);
case 'new':
return {
command,
@@ -94,7 +99,7 @@ export class CommandExecutorService {
};
}
case 'agent':
return await this.handleAgent(args ?? null, conversationId, userId);
return await this.handleAgent(args ?? null, conversationId, scope);
case 'provider':
return await this.handleProvider(args ?? null, userId, conversationId);
case 'mission':
@@ -146,10 +151,11 @@ 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);
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
if (currentOverride) {
return {
command: 'model',
@@ -171,7 +177,7 @@ export class CommandExecutorService {
// /model clear removes the override and re-enables automatic routing
if (modelName === 'clear') {
this.chatGateway?.setModelOverride(conversationId, null);
this.chatGateway?.setModelOverride(conversationId, null, scope);
return {
command: 'model',
conversationId,
@@ -181,9 +187,9 @@ export class CommandExecutorService {
}
// Set the sticky per-session override (M4-007)
this.chatGateway?.setModelOverride(conversationId, modelName);
this.chatGateway?.setModelOverride(conversationId, modelName, scope);
const session = this.agentService.getSession(conversationId);
const session = this.agentService.getSession(conversationId, scope);
if (!session) {
return {
command: 'model',
@@ -224,10 +230,11 @@ 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);
await this.systemOverride.clear(conversationId, scope);
return {
command: 'system',
conversationId,
@@ -236,7 +243,7 @@ export class CommandExecutorService {
};
}
await this.systemOverride.set(conversationId, args.trim());
await this.systemOverride.set(conversationId, args.trim(), scope);
return {
command: 'system',
conversationId,
@@ -248,8 +255,9 @@ export class CommandExecutorService {
private async handleAgent(
args: string | null,
conversationId: string,
userId: string,
scope: ActorTenantScope,
): Promise<SlashCommandResultPayload> {
const userId = scope.userId;
if (!args) {
return {
command: 'agent',
@@ -338,11 +346,14 @@ 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, { agentName: agentConfig.name });
this.chatGateway?.broadcastSessionInfo(conversationId, scope, {
agentName: agentConfig.name,
});
this.logger.log(
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,

View File

@@ -159,6 +159,7 @@ 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(() => {
@@ -170,7 +171,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, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('nonexistent');
expect(result.command).toBe('nonexistent');
@@ -179,7 +180,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, userId);
const result = await executor.execute(payload, userScope);
expect(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
expect(result.success).toBe(true);
expect(result.message).toContain('GC sweep complete');
@@ -190,8 +191,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, userId);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('override set');
});
@@ -199,8 +200,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, userId);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
const result = await executor.execute(payload, userScope);
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('cleared');
});
@@ -212,7 +213,7 @@ describe('CommandExecutorService — integration', () => {
args: 'claude-3-opus',
conversationId,
};
const result = await executor.execute(payload, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('model');
expect(result.message).toContain('claude-3-opus');
@@ -221,7 +222,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, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('high');
});
@@ -229,7 +230,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, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.message).toContain('Usage:');
});
@@ -237,7 +238,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, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe('new');
});
@@ -245,7 +246,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, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(false);
expect(result.message).toContain('ReloadService');
});
@@ -255,7 +256,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, userId);
const result = await executor.execute(payload, userScope);
expect(result.success).toBe(true);
expect(result.command).toBe(cmd);
});

View File

@@ -0,0 +1,255 @@
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
import type { Db } from '@mosaicstack/db';
import type { FederationListResponse } from '@mosaicstack/types';
import {
FederationClientError,
type FederationClientService,
} from '../federation-client.service.js';
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
interface TestRow {
id: string;
title: string;
}
interface PeerRow {
id: string;
commonName: string;
endpointUrl: string | null;
clientKeyPem: string | null;
state: 'active' | 'pending' | 'suspended' | 'revoked';
}
const LOCAL_ROWS: TestRow[] = [
{ id: 'local-1', title: 'Local One' },
{ id: 'local-2', title: 'Local Two' },
];
const PEER_A: PeerRow = {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
commonName: 'peer-a',
endpointUrl: 'https://peer-a.example.com',
clientKeyPem: 'sealed-key-a',
state: 'active',
};
const PEER_B: PeerRow = {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
commonName: 'peer-b',
endpointUrl: 'https://peer-b.example.com',
clientKeyPem: 'sealed-key-b',
state: 'active',
};
const PEER_LOCALHOST: PeerRow = {
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
commonName: 'peer-localhost',
endpointUrl: 'https://localhost:3001',
clientKeyPem: 'sealed-key-c',
state: 'active',
};
function makeDb(activePeers: PeerRow[]): Db {
const orderBy = vi.fn().mockResolvedValue(activePeers);
const where = vi.fn().mockReturnValue({ orderBy });
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
return {
select,
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
} as unknown as Db;
}
function makeFederationClient(
list: (
peerId: string,
resource: string,
request: Record<string, unknown>,
) => Promise<FederationListResponse<TestRow>>,
): FederationClientService {
return {
list: list as unknown as FederationClientService['list'],
} as FederationClientService;
}
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
return Promise.resolve({ items: rows });
}
describe('QuerySourceService', () => {
it('routes source="local" to the local executor and tags rows as local', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'local',
resource: 'tasks',
request: { cursor: 'ignored-for-local-test' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'local-2', title: 'Local Two', _source: 'local' },
],
});
expect(list).not.toHaveBeenCalled();
});
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-1', title: 'Remote One' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:peer-b.example.com',
resource: 'tasks',
request: { status: 'open' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
});
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
});
it('matches federated hosts by endpoint host including non-default port', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-port', title: 'Remote Port' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:localhost:3001',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
});
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
});
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
const callOrder: string[] = [];
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
callOrder.push(`remote-start:${peerId}`);
await Promise.resolve();
return {
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
};
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: { limit: 25 },
local: async () => {
callOrder.push('local-start');
await Promise.resolve();
return { items: [{ id: 'local-1', title: 'Local One' }] };
},
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
],
});
expect(list).toHaveBeenCalledTimes(2);
expect(callOrder).toEqual([
'local-start',
`remote-start:${PEER_A.id}`,
`remote-start:${PEER_B.id}`,
]);
});
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-a', title: 'Remote A' }],
nextCursor: 'remote-next',
}),
);
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
_truncated: true,
});
});
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
if (peerId === PEER_B.id) {
throw new FederationClientError({
code: 'NETWORK',
message: 'peer unavailable',
peerId,
});
}
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
});
});
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
await expect(
service.list<TestRow>({
source: 'federated:missing.example.com',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
}),
).rejects.toMatchObject({
name: 'QuerySourceError',
code: 'PEER_NOT_FOUND',
} satisfies Partial<QuerySourceError>);
});
});

View File

@@ -11,3 +11,13 @@ export {
type FederationClientErrorCode,
type FederationClientErrorOptions,
} from './federation-client.service.js';
export {
QuerySourceService,
QuerySourceError,
type QuerySource,
type QuerySourceErrorCode,
type QuerySourceErrorOptions,
type QuerySourceListOptions,
type QuerySourceListResponse,
type LocalListExecutor,
} from './query-source.service.js';

View File

@@ -0,0 +1,261 @@
/**
* QuerySourceService — gateway query source router (FED-M3-09).
*
* Accepts the federation query-layer `source` selector and routes list-style
* reads to local storage, one federated peer, or all active outbound peers.
*
* `source: "all"` is intentionally tolerant of per-peer failures: local data
* and successful peer responses are returned, and the envelope is marked
* `_partial: true`. Local failures still reject because there is no safe local
* fallback and the gateway's own storage is expected to be authoritative.
*/
import { Inject, Injectable, Logger } from '@nestjs/common';
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
import {
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { DB } from '../../database/database.module.js';
import { FederationClientService } from './federation-client.service.js';
export type QuerySource = 'local' | 'all' | `federated:${string}`;
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
export interface QuerySourceErrorOptions {
code: QuerySourceErrorCode;
message: string;
source: string;
}
export class QuerySourceError extends Error {
readonly code: QuerySourceErrorCode;
readonly source: string;
constructor(opts: QuerySourceErrorOptions) {
super(opts.message);
this.name = 'QuerySourceError';
this.code = opts.code;
this.source = opts.source;
}
}
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
export interface QuerySourceListOptions<T extends object> {
source: QuerySource;
resource: string;
request?: Record<string, unknown>;
local: LocalListExecutor<T>;
}
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
interface OutboundPeer {
id: string;
commonName: string;
endpointUrl: string;
}
interface TaggedList<T extends object> {
items: Array<T & SourceTag>;
partial: boolean;
truncated: boolean;
nextCursor?: string;
}
@Injectable()
export class QuerySourceService {
private readonly logger = new Logger(QuerySourceService.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
) {}
async list<T extends object>(
options: QuerySourceListOptions<T>,
): Promise<QuerySourceListResponse<T>> {
const request = options.request ?? {};
if (options.source === 'local') {
const local = await this.runLocal(options.local);
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
}
if (options.source === 'all') {
return this.listAll(options.resource, request, options.local);
}
if (options.source.startsWith('federated:')) {
const host = options.source.slice('federated:'.length).trim();
if (!host) {
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: 'Federated source must include a host after federated:',
source: options.source,
});
}
const peer = await this.findPeerByHost(host, options.source);
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
return this.toResponse(this.tagList(remote, peer.commonName));
}
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: `Unsupported query source: ${options.source}`,
source: options.source,
});
}
private async listAll<T extends object>(
resource: string,
request: Record<string, unknown>,
local: LocalListExecutor<T>,
): Promise<QuerySourceListResponse<T>> {
const peers = await this.listActiveOutboundPeers();
const localPromise = this.runLocal(local).then((response) =>
this.tagList(response, SOURCE_LOCAL),
);
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
try {
const response = await this.federationClient.list<T>(peer.id, resource, request);
return this.tagList(response, peer.commonName);
} catch (error: unknown) {
this.logger.warn(
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
});
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
const successfulRemoteResults = remoteResults.filter(
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
);
const allResults = [localResult, ...successfulRemoteResults];
const peerFailure = successfulRemoteResults.length !== peers.length;
return this.mergeTaggedLists(allResults, peerFailure);
}
private async runLocal<T extends object>(
local: LocalListExecutor<T>,
): Promise<FederationListResponse<T>> {
const response = await local();
if (Array.isArray(response)) {
return { items: response };
}
return response;
}
private tagList<T extends object>(
response: FederationListResponse<T>,
source: string,
): TaggedList<T> {
return {
items: tagWithSource(response.items, source),
partial: response._partial === true,
truncated: response._truncated === true || response.nextCursor !== undefined,
nextCursor: response.nextCursor,
};
}
private mergeTaggedLists<T extends object>(
lists: Array<TaggedList<T>>,
peerFailure: boolean,
): QuerySourceListResponse<T> {
const items = lists.flatMap((list: TaggedList<T>) => list.items);
const partial =
peerFailure ||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
const response: QuerySourceListResponse<T> = { items };
if (partial) {
response._partial = true;
}
if (truncated) {
response._truncated = true;
}
return response;
}
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
const response: QuerySourceListResponse<T> = {
items: tagged.items,
};
if (tagged.nextCursor !== undefined) {
response.nextCursor = tagged.nextCursor;
}
if (tagged.partial) {
response._partial = true;
}
if (tagged.truncated) {
response._truncated = true;
}
return response;
}
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
const host = normalizeHost(sourceHost);
const peers = await this.listActiveOutboundPeers();
const peer = peers.find((candidate: OutboundPeer) => {
const commonName = normalizeHost(candidate.commonName);
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
normalizeHost(endpointHost),
);
return commonName === host || endpointHosts.includes(host);
});
if (!peer) {
throw new QuerySourceError({
code: 'PEER_NOT_FOUND',
message: `No active outbound federation peer matches source ${source}`,
source,
});
}
return peer;
}
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
const rows = await this.db
.select({
id: federationPeers.id,
commonName: federationPeers.commonName,
endpointUrl: federationPeers.endpointUrl,
})
.from(federationPeers)
.where(
and(
eq(federationPeers.state, 'active'),
isNotNull(federationPeers.endpointUrl),
isNotNull(federationPeers.clientKeyPem),
),
)
.orderBy(federationPeers.commonName);
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase();
}
function endpointHostKeys(endpointUrl: string): string[] {
try {
const url = new URL(endpointUrl);
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
} catch {
return [];
}
}

View File

@@ -4,26 +4,35 @@ import { CaService } from './ca.service.js';
import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GrantsService } from './grants.service.js';
import { FederationClientService } from './client/index.js';
import { FederationAuthGuard } from './server/index.js';
import { FederationClientService, QuerySourceService } from './client/index.js';
import { FederationAuthGuard, FederationScopeService } from './server/index.js';
import { ListController } from './server/verbs/list.controller.js';
import { FederationListQueryService } from './server/verbs/list-query.service.js';
@Module({
controllers: [EnrollmentController, FederationController],
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
providers: [
AdminGuard,
CaService,
EnrollmentService,
GrantsService,
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
exports: [
CaService,
EnrollmentService,
GrantsService,
FederationClientService,
QuerySourceService,
FederationAuthGuard,
FederationScopeService,
FederationListQueryService,
],
})
export class FederationModule {}

View File

@@ -0,0 +1,324 @@
/**
* Unit tests for FederationScopeService (FED-M3-04).
*
* Coverage:
* - resource allowlist deny
* - excluded resource deny
* - invalid scope deny
* - invalid requested limit deny
* - native RBAC deny as subjectUserId
* - scope/native filter intersection for personal and team rows
* - native RBAC personal deny wins over scope include_personal allow/default
* - max_rows_per_query cap
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationScopeService, type FederationNativeRbacEvaluator } from '../scope.service.js';
import type { FederationContext } from '../federation-context.js';
const GRANT_ID = 'grant-1';
const PEER_ID = 'peer-1';
const SUBJECT_USER_ID = 'user-1';
function makeContext(scope: Record<string, unknown>): FederationContext {
return {
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
scope,
};
}
function makeNativeRbac(
result: Awaited<ReturnType<FederationNativeRbacEvaluator['evaluateReadAccess']>>,
): FederationNativeRbacEvaluator {
return {
evaluateReadAccess: vi.fn().mockResolvedValue(result),
};
}
describe('FederationScopeService', () => {
let service: FederationScopeService;
beforeEach(() => {
service = new FederationScopeService();
});
it('allows a granted resource and returns a capped query filter', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-1', 'team-3'], include_personal: true } },
max_rows_per_query: 50,
}),
resource: 'tasks',
requestedLimit: 500,
nativeRbac,
});
expect(result).toEqual({
allowed: true,
filter: {
resource: 'tasks',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: ['team-1'],
limit: 50,
maxRowsPerQuery: 50,
},
});
expect(nativeRbac.evaluateReadAccess).toHaveBeenCalledWith({
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
});
});
it('defaults absent resource filters to native RBAC personal and team visibility', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['notes'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1', 'team-2'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: true,
teamIds: ['team-1', 'team-2'],
limit: 100,
},
});
});
it('honors include_personal false even when native RBAC allows personal rows', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['memory'],
filters: { memory: { include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'memory',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('does not leak personal rows when scope allows personal but native RBAC denies personal', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_personal: true } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: false, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: ['team-1'],
},
});
});
it('does not widen native RBAC when scope includes teams the user cannot access', async () => {
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
filters: { tasks: { include_teams: ['team-2'], include_personal: false } },
max_rows_per_query: 25,
}),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: ['team-1'] },
}),
});
expect(result).toMatchObject({
allowed: true,
filter: {
includePersonal: false,
teamIds: [],
},
});
});
it('denies invalid grant scope before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: [], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
grantId: GRANT_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies unsupported resource names before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'unknown_resource',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_resource',
stage: 'resource_allowlist',
statusCode: 403,
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies resources explicitly present in excluded_resources before allowlist miss', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({
resources: ['tasks'],
excluded_resources: ['credentials'],
max_rows_per_query: 100,
}),
resource: 'credentials',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
resource: 'credentials',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies supported resources that are not granted by scope', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'notes',
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'resource_not_granted',
stage: 'resource_allowlist',
statusCode: 403,
resource: 'notes',
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies invalid requested row limits before RBAC evaluation', async () => {
const nativeRbac = makeNativeRbac({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
requestedLimit: 0,
nativeRbac,
});
expect(result).toMatchObject({
allowed: false,
deny: {
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
details: { requestedLimit: 0 },
},
});
expect(nativeRbac.evaluateReadAccess).not.toHaveBeenCalled();
});
it('denies when native RBAC rejects subjectUserId access to the resource', async () => {
const result = await service.evaluateAccess({
context: makeContext({ resources: ['tasks'], max_rows_per_query: 100 }),
resource: 'tasks',
nativeRbac: makeNativeRbac({
allowed: false,
reason: 'read:tasks denied',
details: { permission: 'tasks:read' },
}),
});
expect(result).toEqual({
allowed: false,
deny: {
code: 'native_rbac_denied',
stage: 'native_rbac',
statusCode: 403,
message: 'read:tasks denied',
grantId: GRANT_ID,
peerId: PEER_ID,
subjectUserId: SUBJECT_USER_ID,
resource: 'tasks',
details: { permission: 'tasks:read' },
},
});
});
});

View File

@@ -10,4 +10,22 @@
*/
export { FederationAuthGuard } from './federation-auth.guard.js';
export { FederationScopeService } from './scope.service.js';
export type { FederationContext } from './federation-context.js';
export type {
FederationNativeRbacAccess,
FederationNativeRbacAllowedResult,
FederationNativeRbacDeniedResult,
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeAllowedResult,
FederationScopeDeniedResult,
FederationScopeDenyCode,
FederationScopeDenyDetails,
FederationScopeDenyReason,
FederationScopeDenyStage,
FederationScopeEvaluationInput,
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from './scope.service.js';

View File

@@ -0,0 +1,272 @@
/**
* FederationScopeService — M3 server-side scope enforcement pipeline.
*
* Pure trust-boundary service: it validates the grant scope, asks an injected
* native RBAC evaluator what the subject user can read locally, intersects that
* answer with the federation scope filters, and returns a query filter for the
* verb controllers. The service performs no DB calls directly.
*/
import { Injectable } from '@nestjs/common';
import {
FEDERATION_RESOURCE_VALUES,
type FederationResource,
FederationScopeError,
parseFederationScope,
} from '../scope-schema.js';
import type { FederationContext } from './federation-context.js';
const federationResourceSet: ReadonlySet<string> = new Set<string>(FEDERATION_RESOURCE_VALUES);
export type FederationScopeDenyStage =
| 'scope_parse'
| 'resource_allowlist'
| 'resource_exclusion'
| 'native_rbac'
| 'row_cap';
export type FederationScopeDenyCode =
| 'invalid_scope'
| 'invalid_resource'
| 'resource_not_granted'
| 'resource_excluded'
| 'native_rbac_denied'
| 'invalid_limit';
export type FederationScopeDenyStatus = 400 | 403;
export interface FederationScopeDenyDetails {
readonly [key: string]: string | number | boolean | readonly string[];
}
export interface FederationScopeDenyReason {
readonly code: FederationScopeDenyCode;
readonly stage: FederationScopeDenyStage;
readonly statusCode: FederationScopeDenyStatus;
readonly message: string;
readonly grantId: string;
readonly peerId: string;
readonly subjectUserId: string;
readonly resource: string;
readonly details?: FederationScopeDenyDetails;
}
export interface FederationNativeRbacRequest {
readonly grantId: string;
readonly peerId: string;
readonly subjectUserId: string;
readonly resource: FederationResource;
}
export interface FederationNativeRbacAccess {
/** Whether this user may read personal rows for this resource. */
readonly includePersonal: boolean;
/** Team IDs this user may read for this resource under native RBAC. */
readonly teamIds: readonly string[];
}
export interface FederationNativeRbacAllowedResult {
readonly allowed: true;
readonly access: FederationNativeRbacAccess;
}
export interface FederationNativeRbacDeniedResult {
readonly allowed: false;
readonly reason?: string;
readonly details?: FederationScopeDenyDetails;
}
export type FederationNativeRbacResult =
| FederationNativeRbacAllowedResult
| FederationNativeRbacDeniedResult;
export interface FederationNativeRbacEvaluator {
evaluateReadAccess(request: FederationNativeRbacRequest): Promise<FederationNativeRbacResult>;
}
export interface FederationScopeEvaluationInput {
readonly context: FederationContext;
readonly resource: string;
readonly requestedLimit?: number;
readonly nativeRbac: FederationNativeRbacEvaluator;
}
export interface FederationScopeQueryFilter {
readonly resource: FederationResource;
readonly subjectUserId: string;
readonly includePersonal: boolean;
readonly teamIds: readonly string[];
readonly limit: number;
readonly maxRowsPerQuery: number;
}
export interface FederationScopeAllowedResult {
readonly allowed: true;
readonly filter: FederationScopeQueryFilter;
}
export interface FederationScopeDeniedResult {
readonly allowed: false;
readonly deny: FederationScopeDenyReason;
}
export type FederationScopeEvaluationResult =
| FederationScopeAllowedResult
| FederationScopeDeniedResult;
function isFederationResource(resource: string): resource is FederationResource {
return federationResourceSet.has(resource);
}
function uniqueStrings(values: readonly string[]): readonly string[] {
return Array.from(new Set<string>(values));
}
function intersectTeamIds(
nativeTeamIds: readonly string[],
scopedTeamIds: readonly string[] | undefined,
): readonly string[] {
const uniqueNativeTeamIds = uniqueStrings(nativeTeamIds);
if (scopedTeamIds === undefined) {
return uniqueNativeTeamIds;
}
const nativeSet = new Set<string>(uniqueNativeTeamIds);
return uniqueStrings(scopedTeamIds).filter((teamId: string): boolean => nativeSet.has(teamId));
}
function makeDenyReason(params: {
readonly code: FederationScopeDenyCode;
readonly stage: FederationScopeDenyStage;
readonly statusCode?: FederationScopeDenyStatus;
readonly message: string;
readonly context: FederationContext;
readonly resource: string;
readonly details?: FederationScopeDenyDetails;
}): FederationScopeDeniedResult {
return {
allowed: false,
deny: {
code: params.code,
stage: params.stage,
statusCode: params.statusCode ?? 403,
message: params.message,
grantId: params.context.grantId,
peerId: params.context.peerId,
subjectUserId: params.context.subjectUserId,
resource: params.resource,
...(params.details !== undefined ? { details: params.details } : {}),
},
};
}
@Injectable()
export class FederationScopeService {
async evaluateAccess(
input: FederationScopeEvaluationInput,
): Promise<FederationScopeEvaluationResult> {
const { context, resource, requestedLimit, nativeRbac } = input;
let scope: ReturnType<typeof parseFederationScope>;
try {
scope = parseFederationScope(context.scope);
} catch (error: unknown) {
const message =
error instanceof FederationScopeError
? 'Federation grant scope is invalid'
: 'Federation grant scope could not be parsed';
const details = error instanceof Error ? { reason: error.message } : undefined;
return makeDenyReason({
code: 'invalid_scope',
stage: 'scope_parse',
statusCode: 400,
message,
context,
resource,
...(details !== undefined ? { details } : {}),
});
}
if (!isFederationResource(resource)) {
return makeDenyReason({
code: 'invalid_resource',
stage: 'resource_allowlist',
message: 'Requested federation resource is not supported',
context,
resource,
details: { supportedResources: FEDERATION_RESOURCE_VALUES },
});
}
if (scope.excluded_resources.includes(resource)) {
return makeDenyReason({
code: 'resource_excluded',
stage: 'resource_exclusion',
message: 'Requested federation resource is explicitly excluded by grant scope',
context,
resource,
});
}
if (!scope.resources.includes(resource)) {
return makeDenyReason({
code: 'resource_not_granted',
stage: 'resource_allowlist',
message: 'Requested federation resource is not granted by scope',
context,
resource,
details: { grantedResources: scope.resources },
});
}
if (requestedLimit !== undefined && (!Number.isInteger(requestedLimit) || requestedLimit < 1)) {
return makeDenyReason({
code: 'invalid_limit',
stage: 'row_cap',
statusCode: 400,
message: 'Requested row limit must be a positive integer',
context,
resource,
details: { requestedLimit },
});
}
const nativeResult = await nativeRbac.evaluateReadAccess({
grantId: context.grantId,
peerId: context.peerId,
subjectUserId: context.subjectUserId,
resource,
});
if (!nativeResult.allowed) {
return makeDenyReason({
code: 'native_rbac_denied',
stage: 'native_rbac',
message: nativeResult.reason ?? 'Subject user is not allowed to read this resource',
context,
resource,
...(nativeResult.details !== undefined ? { details: nativeResult.details } : {}),
});
}
const scopeFilter = scope.filters?.[resource];
const includePersonal =
Boolean(scopeFilter?.include_personal ?? true) && nativeResult.access.includePersonal;
const teamIds = intersectTeamIds(nativeResult.access.teamIds, scopeFilter?.include_teams);
const limit = Math.min(requestedLimit ?? scope.max_rows_per_query, scope.max_rows_per_query);
return {
allowed: true,
filter: {
resource,
subjectUserId: context.subjectUserId,
includePersonal,
teamIds,
limit,
maxRowsPerQuery: scope.max_rows_per_query,
},
};
}
}

View File

@@ -0,0 +1,88 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import type { FastifyRequest } from 'fastify';
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
import { FederationScopeError } from '../../../scope-schema.js';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import { CapabilitiesController } from '../capabilities.controller.js';
const VALID_SCOPE = {
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
} as const;
const DEFAULTED_SCOPE = {
resources: ['memory'],
max_rows_per_query: 10,
} as const;
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
return {
federationContext: {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope,
},
} as FastifyRequest;
}
describe('CapabilitiesController', () => {
it('declares GET /api/federation/v1/capabilities', () => {
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
'api/federation/v1/capabilities',
);
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
RequestMethod.GET,
);
});
it('is protected only by FederationAuthGuard', () => {
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
expect(guards).toEqual([FederationAuthGuard]);
});
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
expect(response).toEqual({
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
supported_verbs: [...FEDERATION_VERBS],
});
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
});
it('applies scope defaults without RBAC or resource filtering', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
expect(response).toEqual({
resources: ['memory'],
excluded_resources: [],
max_rows_per_query: 10,
supported_verbs: ['list', 'get', 'capabilities'],
});
});
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
const controller = new CapabilitiesController();
expect(() =>
controller.getCapabilities(
makeRequest({
resources: [],
max_rows_per_query: 0,
}),
),
).toThrow(FederationScopeError);
});
});

View File

@@ -0,0 +1,428 @@
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
createPgliteDb,
insights,
missionTasks,
missions,
preferences,
projects,
runPgliteMigrations,
teams,
users,
type Db,
type DbHandle,
} from '@mosaicstack/db';
import type { FederationScopeQueryFilter } from '../../scope.service.js';
import { FederationListQueryService } from '../list-query.service.js';
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: [],
limit: 2,
maxRowsPerQuery: 2,
};
const SUBJECT_USER_ID = 'fed-m3-05-subject';
const OTHER_USER_ID = 'fed-m3-05-other';
const TEAM_ID = '05000000-0000-4000-8000-000000000001';
const UNAUTHORIZED_TEAM_ID = '05000000-0000-4000-8000-000000000002';
const PERSONAL_PROJECT_ID = '05000000-0000-4000-8000-000000000101';
const TEAM_PROJECT_ID = '05000000-0000-4000-8000-000000000102';
const UNAUTHORIZED_PROJECT_ID = '05000000-0000-4000-8000-000000000103';
const PERSONAL_MISSION_ID = '05000000-0000-4000-8000-000000000201';
const TEAM_MISSION_ID = '05000000-0000-4000-8000-000000000202';
const UNAUTHORIZED_MISSION_ID = '05000000-0000-4000-8000-000000000203';
const SUBJECT_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000301';
const OTHER_TEAM_NOTE_ID = '05000000-0000-4000-8000-000000000302';
const SUBJECT_PERSONAL_NOTE_ID = '05000000-0000-4000-8000-000000000303';
const SUBJECT_UNAUTHORIZED_NOTE_ID = '05000000-0000-4000-8000-000000000304';
const INSIGHT_ONE_ID = '05000000-0000-4000-8000-000000000401';
const INSIGHT_TWO_ID = '05000000-0000-4000-8000-000000000402';
const PREFERENCE_ONE_ID = '05000000-0000-4000-8000-000000000501';
const PREFERENCE_TWO_ID = '05000000-0000-4000-8000-000000000502';
let dbHandle: DbHandle | undefined;
function makeService() {
return new FederationListQueryService({} as Db);
}
function makeDbService() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
return new FederationListQueryService(dbHandle.db);
}
async function seedNotesFixture() {
if (!dbHandle) {
throw new Error('test DB not initialized');
}
await dbHandle.db.insert(users).values([
{
id: SUBJECT_USER_ID,
name: 'Federation Subject',
email: `${SUBJECT_USER_ID}@example.test`,
emailVerified: false,
},
{
id: OTHER_USER_ID,
name: 'Federation Other',
email: `${OTHER_USER_ID}@example.test`,
emailVerified: false,
},
]);
await dbHandle.db.insert(teams).values([
{
id: TEAM_ID,
name: 'FED-M3-05 Team',
slug: 'fed-m3-05-team',
ownerId: SUBJECT_USER_ID,
managerId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_TEAM_ID,
name: 'FED-M3-05 Unauthorized Team',
slug: 'fed-m3-05-unauthorized-team',
ownerId: OTHER_USER_ID,
managerId: OTHER_USER_ID,
},
]);
await dbHandle.db.insert(projects).values([
{
id: PERSONAL_PROJECT_ID,
name: 'FED-M3-05 Personal Project',
ownerId: SUBJECT_USER_ID,
ownerType: 'user',
},
{
id: TEAM_PROJECT_ID,
name: 'FED-M3-05 Team Project',
teamId: TEAM_ID,
ownerType: 'team',
},
{
id: UNAUTHORIZED_PROJECT_ID,
name: 'FED-M3-05 Unauthorized Project',
teamId: UNAUTHORIZED_TEAM_ID,
ownerType: 'team',
},
]);
await dbHandle.db.insert(missions).values([
{
id: PERSONAL_MISSION_ID,
name: 'FED-M3-05 Personal Mission',
projectId: PERSONAL_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: TEAM_MISSION_ID,
name: 'FED-M3-05 Team Mission',
projectId: TEAM_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
{
id: UNAUTHORIZED_MISSION_ID,
name: 'FED-M3-05 Unauthorized Mission',
projectId: UNAUTHORIZED_PROJECT_ID,
userId: SUBJECT_USER_ID,
},
]);
await dbHandle.db.insert(missionTasks).values([
{
id: SUBJECT_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on team mission',
createdAt: new Date('2026-06-24T03:00:00.000Z'),
updatedAt: new Date('2026-06-24T03:00:00.000Z'),
},
{
id: OTHER_TEAM_NOTE_ID,
missionId: TEAM_MISSION_ID,
userId: OTHER_USER_ID,
notes: 'other user note on team mission',
createdAt: new Date('2026-06-24T02:00:00.000Z'),
updatedAt: new Date('2026-06-24T02:00:00.000Z'),
},
{
id: SUBJECT_PERSONAL_NOTE_ID,
missionId: PERSONAL_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note on personal mission',
createdAt: new Date('2026-06-24T01:00:00.000Z'),
updatedAt: new Date('2026-06-24T01:00:00.000Z'),
},
{
id: SUBJECT_UNAUTHORIZED_NOTE_ID,
missionId: UNAUTHORIZED_MISSION_ID,
userId: SUBJECT_USER_ID,
notes: 'subject note outside grant-visible missions',
createdAt: new Date('2026-06-24T04:00:00.000Z'),
updatedAt: new Date('2026-06-24T04:00:00.000Z'),
},
]);
const memoryCreatedAt = new Date('2026-06-24T05:00:00.000Z');
await dbHandle.db.insert(insights).values([
{
id: INSIGHT_ONE_ID,
userId: SUBJECT_USER_ID,
content: 'first insight',
source: 'agent',
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
{
id: INSIGHT_TWO_ID,
userId: SUBJECT_USER_ID,
content: 'second insight',
source: 'agent',
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
]);
await dbHandle.db.insert(preferences).values([
{
id: PREFERENCE_ONE_ID,
userId: SUBJECT_USER_ID,
key: 'fed-m3-05-pref-1',
value: { enabled: true },
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
{
id: PREFERENCE_TWO_ID,
userId: SUBJECT_USER_ID,
key: 'fed-m3-05-pref-2',
value: { enabled: false },
createdAt: memoryCreatedAt,
updatedAt: memoryCreatedAt,
},
]);
}
function stubRows(
service: FederationListQueryService,
...pages: Array<Array<Record<string, unknown>>>
) {
const mock = vi.fn();
for (const page of pages) {
mock.mockResolvedValueOnce(page);
}
(
service as unknown as {
listAllRows: (
_filter: FederationScopeQueryFilter,
_rowLimit: number,
_cursor: unknown,
) => Promise<Array<Record<string, unknown>>>;
}
).listAllRows = mock;
return mock;
}
describe('FederationListQueryService', () => {
beforeAll(async () => {
dbHandle = createPgliteDb(`memory://fed-m3-05-list-${Date.now()}`);
await runPgliteMigrations(dbHandle);
await seedNotesFixture();
});
afterAll(async () => {
await dbHandle?.close();
dbHandle = undefined;
});
it('denies sensitive resources in native RBAC for M3 list reads', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
}),
).resolves.toMatchObject({
allowed: false,
reason: 'credentials federation list access is not implemented in M3',
});
});
it('allows personal memory reads without requiring team lookup', async () => {
const service = makeService();
await expect(
service.evaluateReadAccess({
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'memory',
}),
).resolves.toEqual({
allowed: true,
access: { includePersonal: true, teamIds: [] },
});
});
it('applies the scope row cap and returns an opaque next cursor when truncated', async () => {
const service = makeService();
const listAllRows = stubRows(
service,
[
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') },
],
[{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
);
const firstPage = await service.list({ filter: TASK_FILTER });
expect(firstPage).toEqual({
items: [
{ id: '3', createdAt: new Date('2026-06-24T03:00:00.000Z') },
{ id: '2', createdAt: new Date('2026-06-24T02:00:00.000Z') },
],
truncated: true,
nextCursor: expect.any(String),
});
expect(listAllRows).toHaveBeenNthCalledWith(1, TASK_FILTER, 3, undefined);
const secondPage = await service.list({ filter: TASK_FILTER, cursor: firstPage.nextCursor });
expect(secondPage).toEqual({
items: [{ id: '1', createdAt: new Date('2026-06-24T01:00:00.000Z') }],
truncated: false,
});
expect(listAllRows).toHaveBeenNthCalledWith(
2,
TASK_FILTER,
3,
expect.objectContaining({ id: '2' }),
);
});
it('rejects invalid cursors instead of falling back to the first page', async () => {
const service = makeService();
stubRows(service, [{ id: '1' }]);
await expect(service.list({ filter: TASK_FILTER, cursor: 'not-base64-json' })).rejects.toThrow(
'Invalid federation list cursor',
);
});
it('throws when a truncated page cannot encode a resumable cursor', async () => {
const service = makeService();
stubRows(service, [
{ id: '2', createdAt: 'not-a-date' },
{ id: '1', createdAt: 'not-a-date' },
]);
await expect(service.list({ filter: { ...TASK_FILTER, limit: 1 } })).rejects.toThrow(
'Federation list cursor cannot be encoded',
);
});
it('throws on unsupported resources instead of crashing pagination', async () => {
const service = makeService();
await expect(
service.list({
filter: {
...TASK_FILTER,
resource: 'unknown-resource' as FederationScopeQueryFilter['resource'],
},
}),
).rejects.toThrow('Unsupported federation list resource');
});
it('does not leak another user mission task notes through team-scoped note reads', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
const ids = result.items.map((item) => item['id']);
expect(ids).toEqual([SUBJECT_TEAM_NOTE_ID]);
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
});
it('does not return subject personal mission task notes when includePersonal is false', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: false,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
expect(result.items.map((item) => item['id'])).not.toContain(SUBJECT_PERSONAL_NOTE_ID);
});
it('does not return subject notes from missions outside the grant-visible project set', async () => {
const service = makeDbService();
const result = await service.list({
filter: {
resource: 'notes',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [TEAM_ID],
limit: 10,
maxRowsPerQuery: 10,
},
});
const ids = result.items.map((item) => item['id']);
expect(ids).toContain(SUBJECT_PERSONAL_NOTE_ID);
expect(ids).toContain(SUBJECT_TEAM_NOTE_ID);
expect(ids).not.toContain(SUBJECT_UNAUTHORIZED_NOTE_ID);
expect(ids).not.toContain(OTHER_TEAM_NOTE_ID);
});
it('paginates memory deterministically across insights and preferences', async () => {
const service = makeDbService();
const filter: FederationScopeQueryFilter = {
resource: 'memory',
subjectUserId: SUBJECT_USER_ID,
includePersonal: true,
teamIds: [],
limit: 2,
maxRowsPerQuery: 2,
};
const firstPage = await service.list({ filter });
const secondPage = await service.list({ filter, cursor: firstPage.nextCursor });
const firstPageIds = firstPage.items.map((item) => item['id']);
const secondPageIds = secondPage.items.map((item) => item['id']);
const allIds = [...firstPageIds, ...secondPageIds];
expect(firstPage).toMatchObject({ truncated: true, nextCursor: expect.any(String) });
expect(firstPageIds).toEqual([INSIGHT_TWO_ID, INSIGHT_ONE_ID]);
expect(secondPageIds).toEqual([PREFERENCE_TWO_ID, PREFERENCE_ONE_ID]);
expect(new Set(allIds).size).toBe(allIds.length);
});
});

View File

@@ -0,0 +1,188 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import type {
FederationScopeEvaluationResult,
FederationScopeQueryFilter,
} from '../../scope.service.js';
import { ListController } from '../list.controller.js';
import type { FederationListQueryResult } from '../list-query.service.js';
const FEDERATION_CONTEXT = {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope: { resources: ['tasks'], max_rows_per_query: 25 },
};
const TASK_FILTER: FederationScopeQueryFilter = {
resource: 'tasks',
subjectUserId: 'user-1',
includePersonal: true,
teamIds: ['team-1'],
limit: 10,
maxRowsPerQuery: 25,
};
function makeRequest(): FastifyRequest {
return { federationContext: FEDERATION_CONTEXT } as unknown as FastifyRequest;
}
function allowedScope(
filter: FederationScopeQueryFilter = TASK_FILTER,
): FederationScopeEvaluationResult {
return { allowed: true, filter };
}
function makeController(opts?: {
scopeResult?: FederationScopeEvaluationResult;
queryResult?: FederationListQueryResult;
}) {
const scope = {
evaluateAccess: vi.fn().mockResolvedValue(opts?.scopeResult ?? allowedScope()),
};
const query = {
evaluateReadAccess: vi.fn(),
list: vi.fn().mockResolvedValue(
opts?.queryResult ?? {
items: [
{
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
},
],
truncated: false,
},
),
};
return {
controller: new ListController(scope as never, query as never),
scope,
query,
};
}
describe('ListController', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('declares POST /api/federation/v1/list/:resource protected only by FederationAuthGuard', () => {
expect(Reflect.getMetadata('path', ListController)).toBe('api/federation/v1/list');
expect(Reflect.getMetadata('path', ListController.prototype.list)).toBe(':resource');
expect(Reflect.getMetadata('method', ListController.prototype.list)).toBe(RequestMethod.POST);
expect(Reflect.getMetadata('__guards__', ListController)).toEqual([FederationAuthGuard]);
});
it('runs AuthGuard context through ScopeService and returns local-source tagged rows', async () => {
const { controller, scope, query } = makeController();
const response = await controller.list('tasks', makeRequest(), { limit: 10 });
expect(scope.evaluateAccess).toHaveBeenCalledWith({
context: FEDERATION_CONTEXT,
resource: 'tasks',
requestedLimit: 10,
nativeRbac: query,
});
expect(query.list).toHaveBeenCalledWith({ filter: TASK_FILTER, cursor: undefined });
expect(response).toEqual({
items: [
{
id: 'task-1',
title: 'Federated task',
createdAt: new Date('2026-06-24T00:00:00.000Z'),
_source: 'local',
},
],
});
});
it('preserves pagination metadata when row cap truncates the query layer result', async () => {
const { controller } = makeController({
queryResult: {
items: [{ id: 'task-1' }],
nextCursor: 'cursor-2',
truncated: true,
},
});
const response = await controller.list('tasks', makeRequest(), { cursor: 'cursor-1' });
expect(response).toEqual({
items: [{ id: 'task-1', _source: 'local' }],
nextCursor: 'cursor-2',
_truncated: true,
});
});
it('returns a federation error envelope when auth guard context is missing', async () => {
const { controller, scope, query } = makeController();
await expect(
controller.list('tasks', {} as unknown as FastifyRequest, {}),
).rejects.toMatchObject({
response: {
error: {
code: 'unauthorized',
message: 'Federation context missing',
},
},
status: 401,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.list).not.toHaveBeenCalled();
});
it('returns a federation error envelope when scope evaluation denies access', async () => {
const { controller, query } = makeController({
scopeResult: {
allowed: false,
deny: {
code: 'resource_excluded',
stage: 'resource_exclusion',
statusCode: 403,
message: 'Requested federation resource is explicitly excluded by grant scope',
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
resource: 'credentials',
},
},
});
await expect(controller.list('credentials', makeRequest(), {})).rejects.toMatchObject({
response: {
error: {
code: 'scope_violation',
message: 'Requested federation resource is explicitly excluded by grant scope',
},
},
status: 403,
});
expect(query.list).not.toHaveBeenCalled();
});
it('rejects malformed request body fields before querying storage', async () => {
const { controller, scope, query } = makeController();
await expect(controller.list('tasks', makeRequest(), { cursor: 123 })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
await expect(controller.list('tasks', makeRequest(), { limit: false })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
await expect(controller.list('tasks', makeRequest(), { limit: 'abc' })).rejects.toMatchObject({
response: { error: { code: 'invalid_request' } },
status: 400,
});
expect(scope.evaluateAccess).not.toHaveBeenCalled();
expect(query.list).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,38 @@
/**
* Federation capabilities verb (FED-M3-07).
*
* Returns the read-only capability envelope for the active grant attached by
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
* or ScopeService: an active grant is enough to ask what the grant allows.
*/
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FEDERATION_VERBS,
type FederationCapabilitiesResponse,
type FederationVerb,
} from '@mosaicstack/types';
import { parseFederationScope } from '../../scope-schema.js';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
@Controller('api/federation/v1/capabilities')
@UseGuards(FederationAuthGuard)
export class CapabilitiesController {
@Get()
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
if (!request.federationContext) {
throw new Error('Federation context missing after auth guard');
}
const scope = parseFederationScope(request.federationContext.scope);
return {
resources: [...scope.resources],
excluded_resources: [...scope.excluded_resources],
max_rows_per_query: scope.max_rows_per_query,
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
};
}
}

View File

@@ -0,0 +1,408 @@
/**
* Federation list query layer (FED-M3-05).
*
* Read-only DB adapter used by ListController after FederationAuthGuard and
* FederationScopeService have established the subject user, allowed resource,
* native-RBAC intersection, and row cap. Audit writes are intentionally
* deferred to M4.
*/
import { Inject, Injectable } from '@nestjs/common';
import {
and,
desc,
eq,
inArray,
insights,
isNotNull,
lt,
missionTasks,
missions,
or,
preferences,
projects,
tasks,
teamMembers,
type Db,
} from '@mosaicstack/db';
import type {
FederationNativeRbacEvaluator,
FederationNativeRbacRequest,
FederationNativeRbacResult,
FederationScopeQueryFilter,
} from '../scope.service.js';
import { DB } from '../../../database/database.module.js';
export interface FederationListQueryRequest {
readonly filter: FederationScopeQueryFilter;
readonly cursor?: string;
}
export interface FederationListQueryResult<T extends object = Record<string, unknown>> {
readonly items: T[];
readonly nextCursor?: string;
readonly truncated: boolean;
}
type CursorSource = 'insights' | 'preferences';
const CURSOR_SOURCE = Symbol('federationCursorSource');
type RowObject = Record<string, unknown> & { readonly [CURSOR_SOURCE]?: CursorSource };
interface KeysetCursor {
readonly createdAt: Date;
readonly id: string;
readonly source?: CursorSource;
}
function encodeCursor(row: RowObject): string {
const createdAt = row['createdAt'];
const id = row['id'];
if (!(createdAt instanceof Date) || typeof id !== 'string') {
throw new Error('Federation list cursor cannot be encoded');
}
const source = row[CURSOR_SOURCE];
return Buffer.from(
JSON.stringify({ createdAt: createdAt.toISOString(), id, ...(source ? { source } : {}) }),
'utf8',
).toString('base64url');
}
function decodeCursor(cursor: string | undefined): KeysetCursor | undefined {
if (cursor === undefined) {
return undefined;
}
try {
const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as unknown;
if (typeof parsed !== 'object' || parsed === null) {
throw new Error('cursor must be an object');
}
const { createdAt, id, source } = parsed as {
createdAt?: unknown;
id?: unknown;
source?: unknown;
};
if (typeof createdAt !== 'string' || typeof id !== 'string' || id.length === 0) {
throw new Error('cursor is missing createdAt or id');
}
if (source !== undefined && source !== 'insights' && source !== 'preferences') {
throw new Error('cursor source is invalid');
}
const date = new Date(createdAt);
if (Number.isNaN(date.getTime())) {
throw new Error('cursor createdAt is invalid');
}
return { createdAt: date, id, ...(source ? { source } : {}) };
} catch {
throw new Error('Invalid federation list cursor');
}
}
function paginate<T extends RowObject>(rows: T[], limit: number): FederationListQueryResult<T> {
const page = rows.slice(0, limit);
const hasMore = rows.length > limit;
const nextCursor = hasMore ? encodeCursor(page[page.length - 1] ?? {}) : undefined;
return {
items: page,
truncated: hasMore,
...(nextCursor !== undefined ? { nextCursor } : {}),
};
}
function markCursorSource<T extends RowObject>(row: T, source: CursorSource): T {
Object.defineProperty(row, CURSOR_SOURCE, {
value: source,
enumerable: false,
configurable: false,
});
return row;
}
function sortRows(rows: RowObject[]): RowObject[] {
return [...rows].sort((a, b) => {
const aTime = a['createdAt'] instanceof Date ? a['createdAt'].getTime() : 0;
const bTime = b['createdAt'] instanceof Date ? b['createdAt'].getTime() : 0;
if (aTime !== bTime) {
return bTime - aTime;
}
return String(b['id'] ?? '').localeCompare(String(a['id'] ?? ''));
});
}
@Injectable()
export class FederationListQueryService implements FederationNativeRbacEvaluator {
constructor(@Inject(DB) private readonly db: Db) {}
async evaluateReadAccess(
request: FederationNativeRbacRequest,
): Promise<FederationNativeRbacResult> {
if (request.resource === 'credentials' || request.resource === 'api_keys') {
return {
allowed: false,
reason: `${request.resource} federation list access is not implemented in M3`,
details: { resource: request.resource },
};
}
if (request.resource === 'memory') {
return { allowed: true, access: { includePersonal: true, teamIds: [] } };
}
const teamIds = await this.listSubjectTeamIds(request.subjectUserId);
return { allowed: true, access: { includePersonal: true, teamIds } };
}
async list<T extends RowObject = RowObject>(
request: FederationListQueryRequest,
): Promise<FederationListQueryResult<T>> {
const cursor = decodeCursor(request.cursor);
const rows = await this.listAllRows(request.filter, request.filter.limit + 1, cursor);
return paginate(rows as T[], request.filter.limit);
}
private async listAllRows(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
switch (filter.resource) {
case 'tasks':
return this.listTasks(filter, rowLimit, cursor);
case 'notes':
return this.listNotes(filter, rowLimit, cursor);
case 'memory':
return this.listMemory(filter, rowLimit, cursor);
case 'credentials':
case 'api_keys':
return [];
default:
throw new Error(`Unsupported federation list resource: ${String(filter.resource)}`);
}
}
private async listSubjectTeamIds(subjectUserId: string): Promise<string[]> {
const rows = await this.db
.select({ teamId: teamMembers.teamId })
.from(teamMembers)
.where(eq(teamMembers.userId, subjectUserId));
return rows.map((row) => row.teamId);
}
private async listAccessibleProjectIds(filter: FederationScopeQueryFilter): Promise<string[]> {
const clauses = [];
if (filter.includePersonal) {
clauses.push(and(eq(projects.ownerType, 'user'), eq(projects.ownerId, filter.subjectUserId)));
}
if (filter.teamIds.length > 0) {
clauses.push(
and(eq(projects.ownerType, 'team'), inArray(projects.teamId, [...filter.teamIds])),
);
}
if (clauses.length === 0) {
return [];
}
const rows = await this.db
.select({ id: projects.id })
.from(projects)
.where(clauses.length === 1 ? clauses[0] : or(...clauses));
return rows.map((row) => row.id);
}
private async listMissionIds(projectIds: readonly string[]): Promise<string[]> {
if (projectIds.length === 0) {
return [];
}
const rows = await this.db
.select({ id: missions.id })
.from(missions)
.where(inArray(missions.projectId, [...projectIds]));
return rows.map((row) => row.id);
}
private async listTasks(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
const clauses = [];
if (projectIds.length > 0) {
clauses.push(inArray(tasks.projectId, projectIds));
}
if (missionIds.length > 0) {
clauses.push(inArray(tasks.missionId, missionIds));
}
if (clauses.length === 0) {
return [];
}
const scopeClause = clauses.length === 1 ? clauses[0] : or(...clauses);
const cursorClause = cursor
? or(
lt(tasks.createdAt, cursor.createdAt),
and(eq(tasks.createdAt, cursor.createdAt), lt(tasks.id, cursor.id)),
)
: undefined;
const rows = await this.db
.select({
id: tasks.id,
title: tasks.title,
description: tasks.description,
status: tasks.status,
priority: tasks.priority,
projectId: tasks.projectId,
missionId: tasks.missionId,
assignee: tasks.assignee,
tags: tasks.tags,
dueDate: tasks.dueDate,
metadata: tasks.metadata,
createdAt: tasks.createdAt,
updatedAt: tasks.updatedAt,
})
.from(tasks)
.where(and(scopeClause, cursorClause))
.orderBy(desc(tasks.createdAt), desc(tasks.id))
.limit(rowLimit);
return sortRows(rows as RowObject[]);
}
private async listNotes(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
const projectIds = await this.listAccessibleProjectIds(filter);
const missionIds = await this.listMissionIds(projectIds);
if (missionIds.length === 0) {
return [];
}
// mission_tasks rows are user-scoped even when the mission belongs to a team.
// Team visibility can narrow the mission set, but it must never widen the
// query to other users' mission task notes.
const scopeClause = and(
eq(missionTasks.userId, filter.subjectUserId),
inArray(missionTasks.missionId, missionIds),
);
const cursorClause = cursor
? or(
lt(missionTasks.createdAt, cursor.createdAt),
and(eq(missionTasks.createdAt, cursor.createdAt), lt(missionTasks.id, cursor.id)),
)
: undefined;
const rows = await this.db
.select({
id: missionTasks.id,
missionId: missionTasks.missionId,
taskId: missionTasks.taskId,
status: missionTasks.status,
content: missionTasks.notes,
createdAt: missionTasks.createdAt,
updatedAt: missionTasks.updatedAt,
})
.from(missionTasks)
.where(and(scopeClause, cursorClause, isNotNull(missionTasks.notes)))
.orderBy(desc(missionTasks.createdAt), desc(missionTasks.id))
.limit(rowLimit);
return sortRows(rows.filter((row) => row.content !== '') as RowObject[]);
}
private async listMemory(
filter: FederationScopeQueryFilter,
rowLimit: number,
cursor: KeysetCursor | undefined,
): Promise<RowObject[]> {
if (!filter.includePersonal) {
return [];
}
if (cursor && cursor.source === undefined) {
throw new Error('Invalid federation list cursor');
}
const rows: RowObject[] = [];
// Memory spans two physical tables. To keep pagination deterministic and
// resumable without a SQL UNION, M3 emits a fixed block order: all insights
// first, then preferences. The opaque cursor records which table produced
// the boundary row, so the next page never re-applies one table's keyset to
// the other table (which could duplicate/skip rows at equal timestamps).
if (cursor?.source !== 'preferences') {
const insightCursorClause = cursor
? or(
lt(insights.createdAt, cursor.createdAt),
and(eq(insights.createdAt, cursor.createdAt), lt(insights.id, cursor.id)),
)
: undefined;
const insightRows = await this.db
.select({
id: insights.id,
kind: insights.source,
content: insights.content,
category: insights.category,
relevanceScore: insights.relevanceScore,
metadata: insights.metadata,
createdAt: insights.createdAt,
updatedAt: insights.updatedAt,
})
.from(insights)
.where(and(eq(insights.userId, filter.subjectUserId), insightCursorClause))
.orderBy(desc(insights.createdAt), desc(insights.id))
.limit(rowLimit);
rows.push(...(insightRows as RowObject[]).map((row) => markCursorSource(row, 'insights')));
}
const remaining = rowLimit - rows.length;
if (remaining <= 0) {
return rows;
}
const preferenceCursorClause =
cursor?.source === 'preferences'
? or(
lt(preferences.createdAt, cursor.createdAt),
and(eq(preferences.createdAt, cursor.createdAt), lt(preferences.id, cursor.id)),
)
: undefined;
const preferenceRows = await this.db
.select({
id: preferences.id,
kind: preferences.category,
key: preferences.key,
value: preferences.value,
source: preferences.source,
mutable: preferences.mutable,
createdAt: preferences.createdAt,
updatedAt: preferences.updatedAt,
})
.from(preferences)
.where(and(eq(preferences.userId, filter.subjectUserId), preferenceCursorClause))
.orderBy(desc(preferences.createdAt), desc(preferences.id))
.limit(remaining);
rows.push(
...(preferenceRows as RowObject[]).map((row) => markCursorSource(row, 'preferences')),
);
return rows;
}
}

View File

@@ -0,0 +1,147 @@
/**
* Federation list verb (FED-M3-05).
*
* POST /api/federation/v1/list/:resource
*
* Pipeline: FederationAuthGuard attaches the active grant context, then
* FederationScopeService enforces grant scope + native RBAC intersection, then
* the read-only query layer returns capped rows tagged with `_source`. Read
* audit-log writes are deferred to M4; this controller does not persist request
* or response bodies.
*/
import {
Body,
Controller,
HttpException,
Inject,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FederationInvalidRequestError,
FederationScopeViolationError,
FederationUnauthorizedError,
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
import { FederationScopeService } from '../scope.service.js';
import { FederationListQueryService } from './list-query.service.js';
interface FederationListRequestBody {
readonly limit?: unknown;
readonly cursor?: unknown;
}
type FederatedRow = Record<string, unknown> & SourceTag;
function parseLimit(body: FederationListRequestBody | undefined): number | undefined {
if (body?.limit === undefined) {
return undefined;
}
const parsed =
typeof body.limit === 'number'
? body.limit
: typeof body.limit === 'string' && body.limit.trim().length > 0
? Number(body.limit)
: Number.NaN;
if (!Number.isSafeInteger(parsed) || parsed < 1) {
throw new HttpException(
new FederationInvalidRequestError(
'Federation list limit must be a positive integer',
).toEnvelope(),
400,
);
}
return parsed;
}
function parseCursor(body: FederationListRequestBody | undefined): string | undefined {
if (body?.cursor === undefined) {
return undefined;
}
if (typeof body.cursor === 'string') {
return body.cursor;
}
throw new HttpException(
new FederationInvalidRequestError('Federation list cursor must be a string').toEnvelope(),
400,
);
}
@Controller('api/federation/v1/list')
@UseGuards(FederationAuthGuard)
export class ListController {
constructor(
@Inject(FederationScopeService) private readonly scope: FederationScopeService,
@Inject(FederationListQueryService) private readonly query: FederationListQueryService,
) {}
@Post(':resource')
async list(
@Param('resource') resource: string,
@Req() request: FastifyRequest,
@Body() body?: FederationListRequestBody,
): Promise<FederationListResponse<FederatedRow>> {
if (!request.federationContext) {
throw new HttpException(
new FederationUnauthorizedError('Federation context missing').toEnvelope(),
401,
);
}
const requestedLimit = parseLimit(body);
const cursor = parseCursor(body);
const scopeResult = await this.scope.evaluateAccess({
context: request.federationContext,
resource,
requestedLimit,
nativeRbac: this.query,
});
if (!scopeResult.allowed) {
const ErrorClass =
scopeResult.deny.statusCode === 400
? FederationInvalidRequestError
: FederationScopeViolationError;
throw new HttpException(
new ErrorClass(scopeResult.deny.message, scopeResult.deny).toEnvelope(),
scopeResult.deny.statusCode,
);
}
let result: Awaited<ReturnType<FederationListQueryService['list']>>;
try {
result = await this.query.list({ filter: scopeResult.filter, cursor });
} catch (error: unknown) {
if (error instanceof Error && error.message === 'Invalid federation list cursor') {
throw new HttpException(
new FederationInvalidRequestError('Federation list cursor is invalid').toEnvelope(),
400,
);
}
throw error;
}
const response: FederationListResponse<FederatedRow> = {
items: tagWithSource(result.items, SOURCE_LOCAL),
};
if (result.nextCursor !== undefined) {
response.nextCursor = result.nextCursor;
}
if (result.truncated) {
response._truncated = true;
}
return response;
}
}

View File

@@ -3,7 +3,11 @@ 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 type { McpService } from './mcp.service.js';
import {
createMcpActorContext,
deriveMcpToolScopesForUser,
type McpService,
} from './mcp.service.js';
import { AUTH } from '../auth/auth.tokens.js';
/**
@@ -67,14 +71,25 @@ async function handleMcpRequest(
return;
}
const userId = result.user.id;
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 }),
});
// ─── 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);
const transport = mcpService.getSession(sessionId, actor);
if (!transport) {
logger.warn(`MCP session not found: ${sessionId}`);
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
@@ -112,8 +127,10 @@ async function handleMcpRequest(
}
// Create new session and handle this initializing request
const { transport } = mcpService.createSession(userId);
logger.log(`New MCP session created for user ${userId}`);
const { transport } = mcpService.createSession(actor);
logger.log(
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
await transport.handleRequest(req.raw, reply.raw, body);
}

View File

@@ -0,0 +1,461 @@
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();
});
});

View File

@@ -10,11 +10,216 @@ 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()
@@ -33,13 +238,18 @@ 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(userId: string): { sessionId: string; transport: StreamableHTTPServerTransport } {
createSession(actor: McpActorContext): {
sessionId: string;
transport: StreamableHTTPServerTransport;
} {
const sessionId = randomUUID();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => sessionId,
onsessioninitialized: (id) => {
this.logger.log(`MCP session initialized: ${id} for user ${userId}`);
this.logger.log(
`MCP session initialized: ${id} for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
);
},
});
@@ -48,7 +258,7 @@ export class McpService implements OnModuleDestroy {
{ capabilities: { tools: {} } },
);
this.registerTools(server, userId);
this.registerTools(server, actor);
transport.onclose = () => {
this.logger.log(`MCP session closed: ${sessionId}`);
@@ -61,31 +271,126 @@ export class McpService implements OnModuleDestroy {
);
});
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), userId });
this.sessions.set(sessionId, { server, transport, createdAt: new Date(), actor });
return { sessionId, transport };
}
/**
* Returns the transport for an existing session, or null if not found.
* 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.
*/
getSession(sessionId: string): StreamableHTTPServerTransport | null {
return this.sessions.get(sessionId)?.transport ?? null;
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)),
);
}
/**
* Registers all platform tools on the given McpServer instance.
*/
private registerTools(server: McpServer, _userId: string): void {
registerTools(server: McpServer, actor: McpActorContext): void {
// ─── Brain: Project tools ────────────────────────────────────────────
server.registerTool(
'brain_list_projects',
{
description: 'List all projects in the brain.',
inputSchema: z.object({}),
inputSchema: strictObject({}),
},
async () => {
const projects = await this.brain.projects.findAll();
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
const projects = filterProjectsForActor(
actor,
(await this.brain.projects.findAll()) as ProjectLike[],
);
return {
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
};
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
'brain_get_project',
{
description: 'Get a project by ID.',
inputSchema: z.object({
inputSchema: strictObject({
id: z.string().describe('Project ID (UUID)'),
}),
},
async ({ id }) => {
const project = await this.brain.projects.findById(id);
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;
return {
content: [
{
type: 'text' as const,
text: project ? JSON.stringify(project, null, 2) : `Project not found: ${id}`,
text: authorizedProject
? JSON.stringify(authorizedProject, null, 2)
: `Project not found: ${id}`,
},
],
};
@@ -119,20 +428,23 @@ export class McpService implements OnModuleDestroy {
'brain_list_tasks',
{
description: 'List tasks, optionally filtered by project, mission, or status.',
inputSchema: z.object({
inputSchema: strictObject({
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 ({ projectId, missionId, status }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_tasks', params);
const { projectId, missionId, status } = params;
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();
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
const scopedTasks = await this.filterTasksForActor(actor, tasks as TaskLike[]);
return { content: [{ type: 'text' as const, text: JSON.stringify(scopedTasks, null, 2) }] };
},
);
@@ -140,7 +452,7 @@ export class McpService implements OnModuleDestroy {
'brain_create_task',
{
description: 'Create a new task in the brain.',
inputSchema: z.object({
inputSchema: strictObject({
title: z.string().describe('Task title'),
description: z.string().optional().describe('Task description'),
projectId: z.string().optional().describe('Project ID'),
@@ -149,6 +461,8 @@ 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,
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
'brain_update_task',
{
description: 'Update an existing task.',
inputSchema: z.object({
inputSchema: strictObject({
id: z.string().describe('Task ID'),
title: z.string().optional(),
description: z.string().optional(),
@@ -171,9 +485,17 @@ 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, {
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
'brain_list_missions',
{
description: 'List all missions, optionally filtered by project.',
inputSchema: z.object({
inputSchema: strictObject({
projectId: z.string().optional().describe('Filter by project ID'),
}),
},
async ({ projectId }) => {
const missions = projectId
? await this.brain.missions.findByProject(projectId)
: await this.brain.missions.findAll();
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[],
);
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
},
);
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
server.registerTool(
'brain_list_conversations',
{
description: 'List conversations for a user.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
}),
description: 'List conversations for the authenticated MCP actor.',
inputSchema: strictObject({}),
},
async ({ userId }) => {
const conversations = await this.brain.conversations.findAll(userId);
async (params) => {
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
const conversations = await this.brain.conversations.findAll(actor.userId);
return {
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
};
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
'memory_search',
{
description:
'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'),
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
inputSchema: strictObject({
query: z.string().describe('Natural language search query'),
limit: z.number().optional().describe('Max results (default 5)'),
}),
},
async ({ userId, query, limit }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_search', params);
const { query, limit } = params;
if (!this.embeddings.available) {
return {
content: [
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
};
}
const embedding = await this.embeddings.embed(query);
const results = await this.memory.insights.searchByEmbedding(userId, embedding, limit ?? 5);
const results = await this.memory.insights.searchByEmbedding(
actor.userId,
embedding,
limit ?? 5,
);
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
},
);
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
server.registerTool(
'memory_get_preferences',
{
description: 'Retrieve stored preferences for a user.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
description: 'Retrieve stored preferences for the authenticated MCP actor.',
inputSchema: strictObject({
category: z
.string()
.optional()
.describe('Filter by category: communication, coding, workflow, appearance, general'),
}),
},
async ({ userId, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_get_preferences', params);
const { category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
const prefs = category
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
: await this.memory.preferences.findByUser(userId);
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
: await this.memory.preferences.findByUser(actor.userId);
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
},
);
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_preference',
{
description:
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
inputSchema: z.object({
userId: z.string().describe('User ID'),
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
inputSchema: strictObject({
key: z.string().describe('Preference key'),
value: z.string().describe('Preference value (JSON string)'),
category: z
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
.describe('Category: communication, coding, workflow, appearance, general'),
}),
},
async ({ userId, key, value, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_preference', params);
const { key, value, category } = params;
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
let parsedValue: unknown;
try {
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
parsedValue = value;
}
const pref = await this.memory.preferences.upsert({
userId,
userId: actor.userId,
key,
value: parsedValue,
category: (category as Cat) ?? 'general',
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
'memory_save_insight',
{
description:
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
inputSchema: z.object({
userId: z.string().describe('User ID'),
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
inputSchema: strictObject({
content: z.string().describe('The insight or knowledge to store'),
category: z
.string()
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
.describe('Category: decision, learning, preference, fact, pattern, general'),
}),
},
async ({ userId, content, category }) => {
async (params) => {
assertMcpToolAuthorized(actor, 'memory_save_insight', params);
const { content, category } = params;
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,
userId: actor.userId,
content,
embedding,
source: 'agent',
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
{
description:
'Get the current orchestration mission status including milestones, tasks, and active session.',
inputSchema: z.object({
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
inputSchema: strictObject({}),
},
async ({ projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const status = await this.coordService.getMissionStatus(resolvedPath);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
const status = await this.coordService.getMissionStatus(process.cwd());
return {
content: [
{
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
'coord_list_tasks',
{
description: 'List all tasks from the orchestration TASKS.md file.',
inputSchema: z.object({
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
inputSchema: strictObject({}),
},
async ({ projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const tasks = await this.coordService.listTasks(resolvedPath);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
const tasks = await this.coordService.listTasks(process.cwd());
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
},
);
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
'coord_task_detail',
{
description: 'Get detailed status for a specific orchestration task.',
inputSchema: z.object({
inputSchema: strictObject({
taskId: z.string().describe('Task ID (e.g. P2-005)'),
projectPath: z
.string()
.optional()
.describe('Project path. Defaults to gateway working directory.'),
}),
},
async ({ taskId, projectPath }) => {
const resolvedPath = projectPath ?? process.cwd();
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
async (params) => {
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
const { taskId } = params;
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
return {
content: [
{

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import {
createDiscordIngressEnvelope,
verifyDiscordIngressEnvelope,
type DiscordIngressPayload,
} from '@mosaicstack/discord-plugin';
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
import { DiscordReplayProtector } from './discord-replay-protector.js';
const SERVICE_TOKEN = 'test-service-token';
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
return {
correlationId: 'correlation-001',
messageId: 'discord-message-001',
guildId: 'guild-001',
channelId: 'channel-001',
userId: 'user-001',
conversationId: 'discord-channel-001',
content: 'hello Tess',
...overrides,
};
}
describe('Discord ingress security', () => {
it('accepts only the configured Discord service identity', () => {
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
});
it('rejects unauthenticated or tampered service envelopes', () => {
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
expect(
verifyDiscordIngressEnvelope(
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
SERVICE_TOKEN,
),
).toBeNull();
});
it.each([
['guild', { guildId: 'unlisted-guild' }],
['channel', { channelId: 'unlisted-channel' }],
['user', { userId: 'unlisted-user' }],
])(
'rejects an unallowlisted Discord %s',
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toBeNull();
},
);
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
const payload = createPayload({
correlationId: 'correlation-trace-123',
messageId: 'discord-snowflake-987',
});
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
expect(
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
guildIds: ['guild-001'],
channelIds: ['channel-001'],
userIds: ['user-001'],
}),
).toEqual(payload);
});
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
const replayProtector = new DiscordReplayProtector(60_000, 2);
expect(replayProtector.claim('discord-message-001')).toBe(true);
expect(replayProtector.claim('discord-message-001')).toBe(false);
expect(replayProtector.claim('discord-message-002')).toBe(true);
expect(replayProtector.claim('discord-message-003')).toBe(true);
expect(replayProtector.size).toBe(2);
});
});

View File

@@ -0,0 +1,40 @@
/**
* Bounded replay cache for Discord's globally unique native message IDs.
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
*/
export class DiscordReplayProtector {
private readonly claimedAt = new Map<string, number>();
constructor(
private readonly ttlMs = 15 * 60 * 1000,
private readonly maxEntries = 10_000,
) {}
get size(): number {
return this.claimedAt.size;
}
/** Claims an ID exactly once within its bounded retention window. */
claim(messageId: string, now = Date.now()): boolean {
this.prune(now);
if (this.claimedAt.has(messageId)) return false;
this.claimedAt.set(messageId, now);
this.evictOverflow();
return true;
}
private prune(now: number): void {
for (const [messageId, claimedAt] of this.claimedAt) {
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
}
}
private evictOverflow(): void {
while (this.claimedAt.size > this.maxEntries) {
const oldestMessageId = this.claimedAt.keys().next().value;
if (oldestMessageId === undefined) return;
this.claimedAt.delete(oldestMessageId);
}
}
}

View File

@@ -50,19 +50,41 @@ 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'),
}),
),
);

View File

@@ -1,9 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
import type { ActorTenantScope } from '../auth/session-scope.js';
const SESSION_SYSTEM_KEY = (sessionId: string) => `mosaic:session:${sessionId}:system`;
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
`mosaic:session:${sessionId}:system:fragments`;
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 SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
interface OverrideFragment {
@@ -20,9 +24,9 @@ export class SystemOverrideService {
this.handle = createQueue();
}
async set(sessionId: string, override: string): Promise<void> {
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
// Load existing fragments
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId));
const existing = await this.handle.redis.get(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope));
const fragments: OverrideFragment[] = existing
? (JSON.parse(existing) as OverrideFragment[])
: [];
@@ -37,11 +41,11 @@ export class SystemOverrideService {
// Store both: fragments array and condensed result
const pipeline = this.handle.redis.pipeline();
pipeline.setex(
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
SYSTEM_OVERRIDE_TTL_SECONDS,
JSON.stringify(fragments),
);
pipeline.setex(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
pipeline.setex(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS, condensed);
await pipeline.exec();
this.logger.debug(
@@ -49,21 +53,21 @@ export class SystemOverrideService {
);
}
async get(sessionId: string): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId, scope));
}
async renew(sessionId: string): Promise<void> {
async renew(sessionId: string, scope: ActorTenantScope): Promise<void> {
const pipeline = this.handle.redis.pipeline();
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
await pipeline.exec();
}
async clear(sessionId: string): Promise<void> {
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
await this.handle.redis.del(
SESSION_SYSTEM_KEY(sessionId),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
SESSION_SYSTEM_KEY(sessionId, scope),
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
);
this.logger.debug(`Cleared system override for session ${sessionId}`);
}

View File

@@ -67,10 +67,11 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
## Workstreams
| # | ID | Name | Status | Manifest | Notes |
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460#466 filed |
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
| # | ID | Name | Status | Manifest | Notes |
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460#466 filed |
| W2 | TESS | Tess interaction agent | planning-complete | [docs/tess/MISSION-MANIFEST.md](./tess/MISSION-MANIFEST.md) | 5 milestones; issue #706; M1 issue #707 ready |
| W3+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
### Likely Additional Workstreams (Not Yet Declared)

View File

@@ -79,6 +79,102 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective
Jason needs one durable, operator-facing Mosaic agent outside Hermes that is reachable through a dedicated Discord channel and CLI, can attach to and operate the Mosaic fleet and transitional Hermes agents, and preserves context across restarts and compaction. Mos remains the coding/general fleet orchestrator; Tess is the complementary human interaction, visibility, control, and migration agent.
The objective is to ship **Tess** (from _tessera_, a piece of a mosaic) as a Pi-native, GPT-5.6 Sol agent with high reasoning. Tess must use Mosaic-owned contracts and plugins so Hermes can be replaced incrementally rather than becoming a permanent architectural dependency.
### Scope
#### In Scope
1. `TESS-ARP-001`: A runtime-neutral `AgentRuntimeProvider` contract supporting `listSessions`, `streamSession`, `sendMessage`, `terminate`, `getSessionTree`, `attach`, health, capability discovery, and normalized events/errors.
2. `TESS-PI-001`: A long-running Pi-native Tess agent profile/service pinned to GPT-5.6 Sol with high reasoning, explicit tool policy, lifecycle hooks, durable checkpoints, and restart recovery.
3. `TESS-DSC-001`: Dedicated Discord channel binding to Tess through the Mosaic gateway, with allowlists/RBAC, thread/reply policy, streaming, attachments, approvals, and correlation IDs.
4. `TESS-CLI-001`: `mosaic tess` CLI commands for chat, status, session listing, attach/detach, send/steer/stop, provider health, and recovery.
5. `TESS-FLT-001`: Fleet plugin capabilities for roster/status/heartbeat inspection, message delivery, session hierarchy, safe attach, and controlled restart/recovery.
6. `TESS-MOS-001`: Explicit Mos coordination boundary and tools: hand off orchestration requests, observe mission/task state, receive results, and never silently compete for orchestration authority.
7. `TESS-HRM-001`: Transitional Hermes adapter for profiles/agents, sessions, streaming/messages, Kanban, skills, memory, tools, cron, and health, using capability negotiation and fail-closed unsupported operations.
8. `TESS-MEM-001`: Unified memory/retrieval plugin with scoped search/recent/capture/stats, startup context injection, provenance, redaction, namespace isolation, and flat-file/project truth precedence.
9. `TESS-STA-001`: Durable agent state, inbox, handoff, compaction-recovery, and resume reconstruction.
10. `TESS-PLG-001`: Plugin/tool catalog covering runtime bootstrap, repository/PR workflow, fleet diagnostics, incident-safe read operations, Discord interaction, and extensible MCP/skill discovery.
11. `TESS-TRN-001`: Replaceable transport providers: tmux/fleet now, Matrix/native Mosaic transport later, with no Discord/CLI business logic coupled to transport details.
12. `TESS-SEC-001`: RBAC, per-operation authorization, explicit approval for destructive/privileged/customer-visible actions, audit events, secret/PII redaction, tenant isolation, and bounded command execution.
13. `TESS-SEC-002`: Command execution SHALL enforce declared scope/role server-side; admin/system and destructive operations SHALL require policy-bound durable approval.
14. `TESS-SEC-003`: Every session list/read/attach/send/terminate operation SHALL enforce server-derived owner and tenant scope; guessed or client-supplied IDs SHALL grant no authority.
15. `TESS-SEC-004`: MCP tools SHALL derive actor/tenant from authenticated context and SHALL NOT accept caller-controlled identity fields.
16. `TESS-SEC-005`: Discord plugin ingress SHALL authenticate service identity, enforce guild/channel/user allowlists, propagate correlation/message IDs, and reject replay.
17. `TESS-SEC-006`: Secret/PII classification and redaction SHALL occur before persistence and before channel egress, including tool metadata and authentication flows.
18. `TESS-SEC-007`: Approvals SHALL be one-time, expiring, actor/tenant-bound, and cryptographically bound to the exact structured action digest.
19. `TESS-SEC-008`: Ingress, provider sends, tool side effects, and responses SHALL use durable inbox/outbox/checkpoints and idempotency records for restart-safe replay.
20. `TESS-SEC-009`: Garbage collection and retention SHALL be session/tenant scoped unless executed as a separately authorized and audited system-wide job.
21. `TESS-OBS-001`: Structured logs, traces, health/readiness, provider latency/errors, session lifecycle, tool audit, and actionable recovery diagnostics.
22. `TESS-MIG-001`: Capability inventory and staged Hermes-to-Mosaic migration matrix with coexistence, cutover, rollback, and deprecation gates.
#### Out of Scope
1. Replacing Mos as coding/general fleet orchestrator.
2. Making Hermes the Mosaic core or coupling Mosaic domain logic to Hermes schemas.
3. Migrating every historical chat verbatim; only policy-compliant indexed summaries and user-selected sessions are migrated.
4. Unrestricted shell execution from Discord.
5. Full web UI parity in the first Tess operational milestone; gateway contracts must remain web-consumable.
6. Replacing tmux before Matrix/native transport reaches operational parity.
### Stakeholder and User Requirements
- Jason must be able to converse with the same Tess session from Discord and CLI.
- Jason must be able to see what is running, stale, blocked, or unhealthy without attaching manually to every session.
- Jason must be able to attach to Tess and authorized fleet sessions through supported CLI controls.
- Tess must collaborate with Mos and the fleet while preserving a single clear orchestration authority.
- The system must migrate useful Hermes/OpenClaw capabilities intentionally, with evidence, instead of copying implementations wholesale.
### Non-Functional Requirements
1. **Security:** default-deny provider/tool capabilities, least privilege, no secrets in logs/prompts/commits, Discord user/channel authorization, and auditable approvals.
2. **Reliability:** durable inbox/checkpoints; idempotent message handling; reconnect with bounded backoff; no message loss or duplicate execution across gateway restart.
3. **Performance:** first acknowledgement within 2 seconds when connected; streamed agent output begins within 5 seconds excluding model/provider delay; status reads return within 2 seconds under nominal local conditions.
4. **Observability:** every ingress message and resulting provider/tool operation carries a correlation ID across Discord, gateway, Tess, provider, and audit events.
5. **Maintainability:** channel, runtime, transport, memory, and external-agent integrations remain adapter-based with contract tests.
6. **Privacy:** only scoped context enters external runtimes; persisted messages/memories follow retention and redaction policy.
7. **Portability:** Tess runs through Pi/Mosaic contracts and does not require Hermes to start or serve native Mosaic operations.
### Acceptance Criteria
1. `AC-TESS-01`: A dedicated Discord channel and `mosaic tess chat` connect to one durable Tess session and stream responses bidirectionally.
2. `AC-TESS-02`: `mosaic tess status|sessions|tree|attach|send|stop` operate against authorized provider capabilities with stable typed outputs and actionable errors.
3. `AC-TESS-03`: Tess runs GPT-5.6 Sol at high reasoning and its effective runtime/model/tool policy is visible through status without exposing credentials.
4. `AC-TESS-04`: Tess can inspect and message the Mosaic fleet, hand orchestration work to Mos, and demonstrate that Tess does not independently claim Mos-owned orchestration work.
5. `AC-TESS-05`: Hermes adapter demonstrates session listing, streaming/message delivery, hierarchy mapping, and at least one approved capability in each of Kanban, skills, memory, tools, and cron—or reports unsupported capabilities fail-closed.
6. `AC-TESS-06`: Restart/compaction test preserves session identity, pending inbox, last durable checkpoint, and a resumable handoff without duplicate side effects.
7. `AC-TESS-07`: Unauthorized Discord users/channels, cross-tenant access, unsafe tool calls, forged approvals, and sensitive-output cases are denied and audited.
8. `AC-TESS-08`: tmux/fleet and Matrix/native transport implementations pass the same provider contract suite; Matrix may remain non-default until readiness gates pass.
9. `AC-TESS-09`: Baseline quality gates, unit/integration/contract tests, Discord+CLI E2E, restart/recovery tests, independent code review, and security review are green.
10. `AC-TESS-10`: Migration matrix documents every audited Hermes/OpenClaw capability as native, adapted, deferred, or rejected, with cutover and rollback evidence.
11. `AC-TESS-11`: User, admin, developer, API/OpenAPI, operations/recovery, and plugin-authoring documentation is current and linked from the sitemap.
### Constraints, Dependencies, Risks, and Assumptions
- Dependency: Mosaic gateway remains the single API surface; Pi is the native runtime; Valkey/PostgreSQL provide canonical durable state where required.
- Dependency: Discord bot credentials and dedicated channel ID are deployment secrets provisioned outside source control.
- Risk: Tess could drift into a second orchestrator. Mitigation: explicit role policy, Mos handoff contract, authority checks, and E2E boundary tests.
- Risk: broad Hermes compatibility can freeze legacy semantics into Mosaic. Mitigation: Mosaic-owned normalized contracts and capability negotiation.
- Risk: Discord creates a privileged remote-control surface. Mitigation: pairing/allowlists, RBAC, approvals, rate limits, audit, and safe tool classes.
- Risk: transcript ingestion can violate privacy or overload memory. Mitigation: scoped opt-in import, redacted summaries, provenance, retention, and deduplication.
- Risk: current root filesystem has limited headroom. Mitigation: isolated worktrees, no duplicated dependency installation unless required, and cleanup only after active-lane verification.
- `ASSUMPTION:` The public name is **Tess**, because the user requested a name and the tessera/Mosaic relationship is distinctive; config must permit later display-name changes without renaming APIs or storage keys.
- `ASSUMPTION:` The dedicated Discord channel ID and final guild policy will be supplied/provisioned during deployment, so implementation uses explicit configuration and fail-fast startup validation.
- `ASSUMPTION:` tmux/fleet is the production transport for the first operational milestone; Matrix/native transport is implemented behind the same contract and promoted only after parity/reliability verification.
- `ASSUMPTION:` Project/task truth remains in canonical Mosaic/project stores; semantic memory systems are retrieval/mirror layers, not hidden authorities.
### Testing and Delivery Intent
Delivery uses five gated milestones: runtime contracts/security; Pi service/state; Discord/CLI; fleet/Hermes/plugin suite; migration/Matrix/recovery/qualification. Every source-code task requires tests, independent review, a PR to `main`, terminal-green CI, and issue/task closure. Production activation additionally requires a clean-host Pi launch, dedicated Discord channel smoke test, CLI attach test, restart/recovery drill, and rollback procedure.
---
## Architecture
### High-Level System Diagram

View File

@@ -14,9 +14,10 @@
## Workstream Rollup
| id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
## Cross-Cutting Tracking

View File

@@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
### 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

View File

@@ -91,22 +91,22 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
>
> **Tracking issue:** #462.
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
| FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
| FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
| FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
| FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
| FED-M3-07 | in-progress | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
| FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
| FED-M3-09 | in-progress | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
| FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
| FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
| FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
| FED-M3-05 | in-progress | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
| FED-M3-07 | done | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
| FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
| FED-M3-09 | done | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-04, M3-05, M3-06, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
@@ -120,6 +120,8 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
**Backlog sync — 2026-06-24 (orchestrator):** Status reconciled against `origin/main` (release 0.0.48). Landed on main: **FED-M3-01** (DTOs, PR #506), **FED-M3-02** (harness scaffold, PR #505), **FED-M3-03** (mTLS auth-guard, PR #509 — CRIT-1/2 + HIGH-1..4 remediated in-PR), **FED-M3-08** (outbound mTLS client, PR #508). With M3-01/03/08 merged, three cards became dependency-clear and were dispatched to the idle coder lane: **FED-M3-04** scope.service → coder0 (`feat/federation-m3-scope-service`); **FED-M3-09** query-source + **FED-M3-07** capabilities verb → coder1 (`feat/federation-m3-query-source` first). Reviewer warmed for the M3 trust-boundary PRs. Remaining blocked-by-DAG: M3-05/06 (await M3-04), M3-10 (await M3-05/06), M3-11 (await M3-09), M3-12→14 (tail). Deploy chain (DEPLOY-IMG-FIX → 03/04) still independent of M3 code — harness local docker-compose fallback covers M3-11.
**Backlog sync #2 — 2026-06-24 (orchestrator):** **FED-M3-09** (query-source) merged via PR #673 and **FED-M3-07** (capabilities) merged via PR #674 — both squash-merged on independent agent review-of-record + green CI (formal Gitea approve unavailable under the shared service account; merge is not gated by the self-approve guard). **FED-M3-05** (list verb) dispatched to coder1 (based on the M3-04 branch, rebase onto main once #672 lands). **FED-M3-04** (scope.service, PR #672) is in review-changes (one include_personal no-leak test outstanding). **DAG fix:** corrected `FED-M3-11` depends_on from `M3-02, M3-09``M3-02, M3-04, M3-05, M3-06, M3-09` — the E2E acceptance cases (#1#5, #8#10) exercise list/get over mTLS, so the server verbs + scope service are hard prerequisites; the original edge set omitted them and caused a premature M3-11 dispatch. Note: M3 read-path invariant for M3-11 is **no-persist + existing enrollment audit only** — read-verb audit-log writes are deferred to M4 (see M3-05/06 notes), so M3-11 must not assert read-audit-log entries.
## Milestone 4 — search + audit + rate limit (FED-M4)
_Deferred. Issue #463._

View File

@@ -293,13 +293,24 @@ 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_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 |
| 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.
### Observability

View File

@@ -0,0 +1,60 @@
# Scratchpad — FED-M3-04 Scope Service
## Objective
Implement `apps/gateway/src/federation/server/scope.service.ts` for the M3 inbound federation scope-enforcement pipeline.
## Scope / Constraints
- Task: FED-M3-04, issue #462.
- Branch: `feat/federation-m3-scope-service` from `origin/main` @ 0.0.48.
- Pure service: no direct DB access; native RBAC/data access is injected per evaluation call.
- Reuse `parseFederationScope` from M2-03.
- Workers do not edit `docs/federation/TASKS.md` per repo AGENTS.md.
## Acceptance Criteria
1. Resource allowlist and `excluded_resources` enforced.
2. Native RBAC evaluated as `subjectUserId` through an injected evaluator.
3. Scope filter intersection supports `include_teams` and `include_personal` without widening native RBAC.
4. `max_rows_per_query` caps requested limits.
5. Service returns `{ allowed: true, filter }` or a structured deny reason usable by M4 audit.
6. Unit tests cover every deny path.
## Plan
1. Inspect existing federation scope/schema/auth guard contracts.
2. Add pure `FederationScopeService` plus typed result/filter/deny interfaces.
3. Add focused unit tests for happy paths, filter intersection, row cap, and deny paths.
4. Export/register service for future verb controllers.
5. Run situational tests, baseline gates, code review, then PR.
## Budget
- Provided model tier: sonnet.
- Estimate from task row: 10K tokens.
- Working cap assumption: keep implementation focused to FED-M3-04 surfaces only.
## Progress
- Intake complete; dirty base worktree avoided by creating isolated worktree at `/home/jarvis/src/mosaic-mono-v1-fed-m3-04`.
- Project PRD and federation task spec reviewed.
- Added `FederationScopeService` with structured allow/deny result types and injected native RBAC evaluator contract.
- Added unit coverage for happy path, row cap, filter intersection, and every deny path.
- Exported/registered the service for upcoming M3 verb controllers.
## Verification Evidence
- `pnpm --filter @mosaicstack/gateway test -- src/federation/server/__tests__/scope.service.spec.ts` — pass (10 tests before review update; 11 tests after adding include_personal no-leak coverage).
- `pnpm build` — pass (23 successful tasks).
- `pnpm typecheck` — pass (41 successful tasks; re-run after review update).
- `pnpm lint` — pass (23 successful tasks; re-run after review update).
- `pnpm format:check` — pass (re-run after review update).
- `pnpm test` — pass after starting local `postgres`/`valkey` and running `pnpm --filter @mosaicstack/db db:push` for the DB-backed cross-user isolation suite (41 successful tasks; gateway 477 passed / 11 skipped).
- Code review: `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — approve, 0 findings.
- Security review: `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — risk none, 0 findings.
## Risks / Blockers
- Issue #462 is already closed in provider output; likely milestone tracking mismatch. Will still reference #462 in PR body unless orchestrator redirects.
- Local full-test setup required `docker compose up -d postgres valkey` + `db:push`; containers were stopped with `docker compose down` after verification.

View File

@@ -0,0 +1,33 @@
# Issue #561 — Bare python on agent hosts
## Objective
Make the durable bootstrap/provisioning guidance ensure agent hosts provide a bare `python` command that resolves to Python 3.
## Scope
- Add Debian/Ubuntu `python-is-python3` to agent-host prerequisites in bootstrap docs.
- Check for actual OS package provisioning scripts and update only if an existing agent-host package install path exists.
- Do not touch live host state.
- Do not update `docs/TASKS.md`; repo guidance says workers read it but never modify it.
## Recon
- Issue #561 confirms repeated `python: command not found` failures from fleet agents that emit `python foo.py`.
- `guides/BOOTSTRAP.md` and `packages/mosaic/framework/guides/BOOTSTRAP.md` are the source and packaged framework copies of the bootstrap guide.
- Targeted repo sweep found no agent-host Debian package provisioning script. Existing `apt-get install` hits are CI/test helper paths or unrelated deployment docs.
## Plan
1. Add a host prerequisite section to both bootstrap guide copies.
2. Include `python-is-python3` in the Debian/Ubuntu package list with an issue comment.
3. Note the non-Debian equivalent as a `/usr/bin/python -> python3` symlink.
4. Validate markdown/diff, run shell syntax checks where applicable, run required review, commit, queue guard, and push.
## Validation Log
- `rg` recon: no existing agent-host Debian package provisioning script; only CI/test helper `apt-get install` paths and unrelated deployment docs.
- `git diff --check`: passed.
- `bash -n packages/mosaic/framework/install.sh tools/install.sh packages/mosaic/framework/tools/bootstrap/init-project.sh packages/mosaic/framework/tools/_scripts/mosaic-bootstrap-repo`: passed. No touched shell scripts.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted`: approved, 0 findings.
- `pnpm format:check`: initially blocked because `node_modules` was absent and `prettier` was unavailable; `pnpm install --frozen-lockfile` initially hit an invalid `/root` pnpm store path. Reran install with `--store-dir /home/hermes/agent-work/.pnpm-store`, then `pnpm format:check` passed.

View File

@@ -0,0 +1,25 @@
# Scratchpad — fleet-personas spec timeout
## Objective
Raise the `@mosaicstack/mosaic` Vitest timeout to 30s at config level so filesystem-backed fleet drift-guard specs (`fleet-personas`, `fleet-profiles`, and siblings) stop false-reding under contended CI.
## Plan
1. Move timeout policy into `packages/mosaic/vitest.config.ts` with `testTimeout: 30_000`.
2. Remove the narrower `fleet-personas.spec.ts` local override so PR #677 fixes the suite class, not one file.
3. Run targeted fleet specs plus typecheck/lint/format gates.
4. Commit, queue guard, push, PR update.
## Evidence
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts` — pass (8 tests; initial narrow fix).
- `pnpm typecheck` — pass (41 tasks; initial narrow fix).
- `pnpm lint` — pass (23 tasks; initial narrow fix).
- `pnpm format:check` — pass after formatting this scratchpad (initial narrow fix).
- Package-wide timeout follow-up:
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts src/commands/fleet-profiles.spec.ts` — pass (24 tests).
- `pnpm --filter @mosaicstack/mosaic test` — pass (44 files / 618 tests).
- `pnpm typecheck` — pass (41 tasks).
- `pnpm lint` — pass (23 tasks).
- `pnpm format:check` — pass.

View File

@@ -0,0 +1,49 @@
# #703 Git Wrapper Interactive and Auth Resilience
## Objective
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
## Scope
- `packages/mosaic/framework/tools/git/issue-create.sh`
- `packages/mosaic/framework/tools/git/detect-platform.sh`
- Git wrapper regression harnesses
- This scratchpad
## Requirements / acceptance evidence
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
2. Explicit command-line fields retain precedence and do not trigger prompt input.
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
## Plan
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
2. Implement minimal helper and parser changes.
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
4. Re-seed deployed framework and run live wrapper verification.
5. Commit, queue guard, push, open PR, and stop for independent review.
## Progress
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
## Verification
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
- PASS: Prettier check for this scratchpad

View File

@@ -0,0 +1,52 @@
# FED-M3-05 — Federation List Verb Scratchpad
## Objective
Implement `POST /api/federation/v1/list/:resource`.
## Scope
- Wire `FederationAuthGuard``FederationScopeService` → read-only list query layer.
- Apply `max_rows_per_query` row cap and return pagination metadata when truncated.
- Tag returned rows with `_source: "local"`.
- Keep audit writes deferred to M4.
- No request/response body persistence.
## Base / branch
- Branch: `feat/federation-m3-verb-list`
- Base: `main` after M3-04 scope service merged via PR #672 (`c739256a`).
## Implementation notes
- Added `ListController` under `apps/gateway/src/federation/server/verbs/`.
- Added `FederationListQueryService` as the read-only query layer and native RBAC evaluator.
- Query resources supported in M3 list path:
- `tasks`: project/mission scoped tasks visible through personal/team project access.
- `notes`: non-empty `mission_tasks.notes` rows visible through personal/team mission access.
- `memory`: user-owned `insights` and `preferences` rows.
- `credentials` / `api_keys`: denied by native RBAC in M3 even if present in scope; sensitive-resource implementation is not part of FED-M3-05.
- Cursor pagination uses an opaque base64url keyset cursor over `(createdAt, id)`; DB reads fetch at most `limit + 1` rows per resource query.
- Reviewer isolation fix: `mission_tasks.notes` rows are always constrained by `missionTasks.userId = subjectUserId` and accessible mission IDs; team scope narrows missions but never widens to other users' mission task notes.
- Follow-up review fix: memory listing now uses deterministic table-block pagination (`insights` first, then `preferences`) with cursor source metadata, so one table's cursor is never applied to the other.
- Follow-up hardening: missing auth-guard context returns a structured federation `unauthorized` envelope; unsupported resources and non-encodable truncated cursors throw instead of silently crashing/truncating.
## Tests
- `pnpm --filter @mosaicstack/gateway test -- list.controller.spec.ts list-query.service.spec.ts` — PASS (16 tests, including PGlite regression coverage for team-scoped notes isolation, unauthorized mission notes exclusion, `includePersonal: false`, deterministic memory pagination, missing context envelope, unsupported resource, and cursor encode failure).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm --filter @mosaicstack/gateway test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup cannot connect to local PostgreSQL on `localhost:5433`. New list tests pass; failure is outside FED-M3-05.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS after follow-up remediation; approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS after follow-up remediation; risk level none, no findings.
- Security-review note: read-path audit logging remains intentionally deferred to M4 per orchestrator clarification and FED-M3-05 scope.
## Risks / follow-up
- Read-path audit logging remains intentionally deferred to M4.

View File

@@ -0,0 +1,65 @@
# FED-M3-07 — Capabilities Verb Scratchpad
## Objective
Implement `GET /api/federation/v1/capabilities` in `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`.
## Scope
- Add read-only capabilities controller under federation server verbs.
- Use `FederationAuthGuard` only; active grant is sufficient and no native RBAC/scope-service eval runs.
- Response shape: `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope.
- Register controller in `FederationModule`.
- Unit-test happy path, defaults, no-context guard seam, and invalid scope handling.
## Constraints / assumptions
- Issue: #462.
- Branch: `feat/federation-m3-verb-capabilities` from `origin/main` (`3eeed04e`).
- Depends on M3-03 auth guard; guard attaches `request.federationContext.scope` after active-grant validation.
- ASSUMPTION: `supported_verbs` is the M3 verb set from `@mosaicstack/types` (`list`, `get`, `capabilities`).
- ASSUMPTION: `filters`/`rate_limit` are intentionally omitted for FED-M3-07 because the cards response shape lists only the four required fields.
- Budget: no explicit hard cap from orchestrator; working cap ~4K-8K tokens for card implementation + tests + PR cycle.
## Plan
1. Write controller unit tests first.
2. Implement controller and module registration.
3. Run scoped tests + typecheck/lint/format.
4. Run Codex code/security review and remediate.
5. Commit, queue guard, push, PR via wrapper.
## Progress
- 2026-06-24: Intake complete; fresh worktree created from origin/main.
- 2026-06-24: Added `CapabilitiesController`, registered it in `FederationModule`, and added 5 unit tests.
- 2026-06-24: Code/security reviews passed with no findings.
## Tests run
- `pnpm --filter @mosaicstack/gateway test -- capabilities.controller.spec.ts` — PASS (5 tests).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit PostgreSQL connection/schema state for the `messages` table. Changed capabilities tests passed; failure is outside FED-M3-07 surface. No `fleet-personas.spec` flake encountered.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
## Risks / blockers
- Full repo `pnpm test` may hit known `fleet-personas.spec` flake per orchestrator; ignore that specific flake if encountered.
- Previous card saw local DB schema issue in `cross-user-isolation.test.ts`; scoped capabilities tests should be authoritative for this surface.
## Acceptance evidence mapping
| Acceptance criterion | Evidence |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| GET `/api/federation/v1/capabilities` exists | Route metadata test in `capabilities.controller.spec.ts`; scoped test PASS |
| Uses active-grant auth guard and no RBAC eval | Guard metadata test confirms only `FederationAuthGuard`; controller has no service injections/RBAC calls; scoped test PASS |
| Response enumerates resources/excluded/max rows/supported verbs from scope | Happy-path/default scope tests + response schema parse; scoped test PASS |
| Read-only/no persistence side effects | Controller only parses request `federationContext.scope` and returns a DTO; no DB/service dependency; code review PASS |

View File

@@ -0,0 +1,67 @@
# FED-M3-09 — Query Source Service Scratchpad
## Objective
Implement `apps/gateway/src/federation/client/query-source.service.ts` for `source: "local" | "federated:<host>" | "all"` routing.
## Scope
- Add QuerySourceService in gateway federation client layer.
- Unit-test local-only, single federated peer, all-source fan-out/merge, and per-peer partial failures.
- Keep `docs/federation/TASKS.md` read-only per project agent guidance.
## Constraints / assumptions
- Issue: #462.
- Branch: `feat/federation-m3-query-source` from `origin/main` (`e0e7be70`).
- ASSUMPTION: `federated:<host>` should match active outbound peers by `commonName` first and by `endpointUrl` host/hostname as compatibility fallback; source tags use `peer.commonName` per `@mosaicstack/types` source-tag docs.
- ASSUMPTION: QuerySourceService provides list/fan-out behavior; get/source routing can be layered later because card acceptance says merge rows.
- ASSUMPTION: `source: "all"` cannot safely return a single continuation cursor for multiple sub-sources; any subquery cursor marks the merged response `_partial: true` + `_truncated: true` while omitting `nextCursor`.
- Budget: no explicit hard cap from orchestrator; working cap ~8K-12K tokens for card 1 implementation + tests + PR cycle.
- OpenBrain unavailable: credential loader failed with missing `/home/jarvis/.config/mosaic/credentials.json`; not blocking code delivery.
## Plan
1. Review federation client/types/db patterns.
2. Write unit tests for source behavior.
3. Implement QuerySourceService and export/register it in FederationModule.
4. Run scoped tests, typecheck, lint, format.
5. Run codex uncommitted review and remediate.
6. Commit, queue guard, push, PR via wrapper.
## Progress
- 2026-06-24: Intake complete; using isolated worktree to avoid dirty orchestrator files in original checkout.
- 2026-06-24: Added QuerySourceService, module export, barrel export, and 7 unit tests.
- 2026-06-24: First Codex review found pagination and port-host matching issues; both remediated with tests.
## Tests run
- `pnpm --filter @mosaicstack/gateway test -- query-source.service.spec.ts` — PASS (7 tests).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit `relation "messages" does not exist` against local PostgreSQL. Changed QuerySource unit tests passed; failure is outside FED-M3-09 surface and appears tied to local DB schema state.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — first pass request-changes, 2 should-fix findings (all-source cursor handling; endpoint port host matching).
- Remediation: `_partial` + `_truncated` when any all-source subquery has `nextCursor`; endpoint match accepts URL `host` and `hostname`; added tests for both.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
## Risks / blockers
- Federation query layer is not yet wired; service API needs to be stable and easy to compose.
- Must avoid hard-failing `source: all` on remote peer failures.
## Acceptance evidence mapping
| Acceptance criterion | Evidence |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| local source returns local rows tagged `_source: local` | `query-source.service.spec.ts` local test; scoped test PASS |
| `federated:<host>` queries selected peer and tags rows with peer source | `query-source.service.spec.ts` commonName/endpoint-host tests; scoped test PASS |
| `all` fans out local + active outbound peers in parallel and merges tagged rows | `query-source.service.spec.ts` all-source call-order/merge test; scoped test PASS |
| per-peer failure on `all` returns `_partial: true`, not throw | `query-source.service.spec.ts` peer failure test; scoped test PASS |

View File

@@ -0,0 +1,58 @@
# Scratchpad — Tess Interaction Agent
## 2026-07-12 — Mission intake
**Objective:** Build a Pi-native GPT-5.6 Sol high-reasoning Mosaic interaction agent, named Tess, as Jason's primary Discord/CLI access point for Mosaic fleet and transitional Hermes capabilities. Tess complements Mos and must not become a competing orchestrator.
**Issue:** #706
**Budget:** No explicit cap provided. Original working estimate was 290K implementation/review tokens. That estimate is superseded after six security prerequisite tasks were added; revised arithmetic total is pending because the calculation tool was blocked by runtime consent. Run at most two workers; prefer one implementation lane plus one independent review/discovery lane. Re-estimate after planning approval and each milestone.
**Evidence gathered:**
- Mosaic already provides Pi lifecycle hooks, fleet/tmux sessions, Matrix connector/controller pieces, typed chat events, Discord/Telegram channel plugins, and command/plugin registries.
- Current `IProviderAdapter` is an LLM model/completion abstraction, not an external agent/session provider.
- Required new seam is `AgentRuntimeProvider`: sessions, stream, message, terminate, hierarchy, attach, health, capabilities.
- Recurring cross-runtime needs: unified memory/retrieval, Discord routing/approvals, agent state/inbox/compaction recovery, runtime bootstrap, fleet/incident controls, and GitOps workflow.
- Project truth must remain in canonical project/Mosaic stores; semantic memory is retrieval/mirror.
**Decisions:**
1. Name: Tess (tessera). Stable machine key `tess`; display name configurable.
2. Mos owns orchestration; Tess delegates Mos-owned work through an explicit coordination contract.
3. Gateway owns ingress/auth/routing; Discord and CLI remain thin clients.
4. tmux/fleet ships first behind an adapter; Matrix/native Mosaic is the forward transport.
5. Hermes integration is transitional and capability-negotiated; unsupported operations fail closed.
6. No unrestricted Discord shell. Privileged/destructive/customer-visible actions require authorization and approval.
**Plan:**
1. Land requirements/architecture/task graph.
2. Deliver runtime contracts and security model.
3. Deliver durable Pi service/state.
4. Deliver Discord and CLI.
5. Deliver fleet/Mos/Hermes/memory/tool plugins.
6. Deliver Matrix/native transport, migration matrix, recovery, docs, and qualification.
**Progress:** Issue #706 created. PRD/manifest/tasks initialized on clean branch `feat/tess-interaction-agent` from `origin/main`.
**Risks:** 14 GB root filesystem headroom; active fleet lanes; broad migration scope; Discord privilege boundary; possible duplicate orchestration authority.
## 2026-07-12 — Independent planning and threat review
**Verdict received:** BLOCK TESS-PLAN-001. Coding remains stopped.
**Blocking findings:** formal threat model absent; verification matrix absent; migration inventory implied but absent; non-existent task paths; AC-TESS-03 lacked a crisp test. Security review also identified command scope bypass, cross-tenant session attachment, MCP actor impersonation, unsafe Discord service ingress, pre-persistence/egress secret leakage, non-durable replay, and globally scoped GC.
**Remediation applied:**
- Added `docs/tess/ARCHITECTURE.md`.
- Added `docs/tess/THREAT-MODEL.md` with TM-01..12.
- Added `docs/tess/VERIFICATION-MATRIX.md` mapping AC-TESS-01..11.
- Added `docs/tess/MIGRATION-INVENTORY.md`.
- Added hard requirements TESS-SEC-002..009.
- Added six prerequisite security tasks before provider/ingress implementation.
- Corrected task paths to existing package surfaces.
- Added explicit GPT-5.6 Sol/high/tool-policy status verification for AC-TESS-03.
**Re-review 1:** BLOCK only on composite `repo` values that looked like nonexistent paths. Remediated by declaring comma-separated roots and validating every root.
**Final focused review:** PASS. Deterministic audit validated all task repository roots with zero missing paths; no planning placeholders remained; security prerequisites still gate Tess exposure; observability traceability is explicit.
**Current gate:** planning PR must merge to `main` with terminal-green CI before any source-code worker starts.

View File

@@ -0,0 +1,35 @@
# Scratchpad — TESS-M1-SEC-004 Discord ingress
- **Task / issue:** TESS-M1-SEC-004 / #707
- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd`
- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs.
- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs.
- **Budget:** Task estimate 28K; no explicit hard cap supplied.
- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work.
## Plan
1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection.
2. Implement the signed Discord ingress envelope and allowlist validation in the plugin.
3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch.
4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review.
## Progress
- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed.
- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks.
- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved.
## Risks / blockers
- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work.
## Verification evidence
- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed.
- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped.
- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`.
- `pnpm typecheck` — passed.
- `pnpm lint` — passed.
- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification.
- Codex security review — no findings; final Codex code review — approved.

71
docs/tess/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,71 @@
# Tess Architecture
## Purpose
Tess is the Mosaic operator interaction plane. Mos remains the coding/general fleet orchestration authority. Tess receives authorized operator intent, presents fleet/session state, delegates Mos-owned work to Mos, and exposes native Mosaic plus transitional external-agent capabilities through normalized providers.
## Component Boundaries
```text
Discord plugin ─┐
├─ authenticated ingress envelope ─> Mosaic Gateway
mosaic tess CLI ┘ │
├─ policy/approval/audit
├─ Tess durable session service (Pi GPT-5.6 Sol high)
├─ AgentRuntimeProvider registry
│ ├─ native Pi provider
│ ├─ fleet/tmux provider
│ ├─ Hermes adapter
│ └─ Matrix/native transport provider
├─ memory/state/inbox plugins
└─ Mos coordination adapter ─> Mos / fleet queue
```
## Core Contract
`AgentRuntimeProvider` is separate from the existing model-completion `IProviderAdapter`. It normalizes external and native agent runtimes without leaking provider-specific schemas.
Required operations:
- `capabilities()` and `health()`
- `listSessions(scope)`
- `getSessionTree(scope)`
- `streamSession(sessionRef, cursor, scope)`
- `sendMessage(sessionRef, message, idempotencyKey, scope)`
- `attach(sessionRef, mode, scope)` / `detach()`
- `terminate(sessionRef, approvalRef, scope)`
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
## Authority Model
| Intent | Owner | Tess behavior |
| --------------------------------------------------------------------------- | ----------------------- | ---------------------------------------------------------------------- |
| Conversation, status, retrieval, safe diagnostics | Tess | Execute within policy |
| Code/project decomposition, worker assignment, reviews, merge orchestration | Mos | Create a correlated handoff and observe result |
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
## Session and State Model
A Tess session has stable `sessionId`, `tenantId`, `ownerId`, provider/runtime identity, ingress bindings, cursor, checkpoint, inbox/outbox, and idempotency records. Discord and CLI bind to the same authorized session. Ownership is verified server-side on every list/read/attach/send/terminate operation.
Valkey may hold ephemeral coordination state; PostgreSQL is canonical for durable session bindings, approvals, audit, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
## Transport Strategy
- **Initial:** fleet/tmux provider, including exact target, socket, identity, heartbeat, and safe attach semantics.
- **Forward:** Matrix/native Mosaic provider using authenticated identity, idempotent transaction IDs, replay cursors, and the same contract suite.
- Discord/CLI never call tmux or Matrix directly.
## Plugin Families
1. Channel: Discord now; other channels later.
2. Runtime: Pi, fleet/tmux, Hermes, Matrix/native.
3. Operator tools: fleet health, Mos handoff, GitOps wrappers, incident-safe diagnostics.
4. Memory/state: search/recent/capture, durable inbox, checkpoint, handoff, compaction recovery.
5. Migration: capability inventory, adapters, cutover, rollback, telemetry.
## Deployment
Tess runs as a rostered, systemd-supervised Pi agent using GPT-5.6 Sol and high reasoning. Secrets are supplied through approved runtime secret mechanisms. Startup fails when required model, gateway identity, Discord binding, or durable-state dependencies are missing. Health reports effective model/reasoning/tool policy without credential material.

View File

@@ -0,0 +1,34 @@
# Tess Capability Migration Inventory
Status values: `native` · `adapt` · `defer` · `reject`. This is the initial inventory; M5 requires implementation and evidence fields to be completed before cutover.
| Capability | Current source | Target | Initial status | Cutover/rollback intent |
| ---------------------------------------- | ---------------------------------------- | ------------------------------------------ | -------------- | ----------------------------------------------------------------------- |
| Interactive agent chat/session streaming | Hermes/Pi/OpenClaw | Mosaic Tess session service | native | Dual-run per channel; revert binding to legacy gateway |
| Discord dedicated-channel routing | Hermes/Claude/OpenClaw plugins | Mosaic Discord plugin + gateway | native | Per-channel binding switch; legacy bot disabled only after soak |
| CLI/TUI session interaction and attach | Hermes/Pi/tmux | `mosaic tess` + AgentRuntimeProvider | native | Keep direct tmux attach as break-glass rollback |
| Session list/tree/send/terminate | Hermes/fleet | AgentRuntimeProvider | native | Capability-negotiated adapter remains during migration |
| Mos/fleet orchestration handoff | tmux messaging/Mosaic fleet | Mosaic coord/fleet provider | native | tmux handoff remains initial transport |
| Kanban/projects/tasks | Hermes Kanban | Mosaic queue/coord/project providers | adapt | Read projection first; mutating cutover after parity/audit |
| Skills catalog/load/manage | Hermes skills/Pi skills | Mosaic skill registry/provider | adapt | Import metadata/provenance; preserve source skill until validated |
| Tools and MCP | Hermes/OpenClaw/MCP | Mosaic tool registry/MCP | adapt | Default deny; migrate allowlisted tools one capability at a time |
| Cron/scheduled work | Hermes cron | Mosaic scheduler/queue | adapt | Shadow schedules; prevent duplicate execution; rollback owner field |
| Memory search/recent/capture | jarvis-brain/OpenViking/OpenBrain/Hermes | Mosaic memory provider | adapt | Flat/project stores remain truth; semantic systems are mirrors |
| User/profile preferences | Hermes memory/user profile | Mosaic user/memory domain | adapt | Provenance + explicit conflict rules; exportable rollback snapshot |
| Agent state/inbox/handoff | OpenClaw extensions/session files | Mosaic durable state service | native | Read legacy handoff during coexistence; write Mosaic only after cutover |
| Runtime contract/bootstrap | Mosaic framework/Hermes/OpenClaw | Mosaic compose/runtime provider | native | Legacy launchers remain until clean-host parity passes |
| Repository/PR workflow | Mosaic wrappers/Hermes tools | Mosaic operator plugin | native | Wrapper-only; no raw-provider fallback |
| Incident-safe diagnostics | Hermes skills/tools | Mosaic scoped operator plugin | adapt | Read-only first; privileged recovery requires approval |
| Broad unrestricted shell from Discord | Hermes/OpenClaw configurations | None | reject | No cutover; replace with allowlisted typed operations |
| Raw full transcript bulk migration | Hermes/Claude/OpenClaw histories | Indexed summaries/selective import | reject | Keep source archives subject to retention; no automatic copy |
| Voice/video interaction | Hermes optional tools | Future Mosaic channel plugins | defer | Not required for Tess operational release |
| Matrix transport | Mosaic connector | AgentRuntimeProvider Matrix implementation | native | Non-default until contract/reliability parity; tmux rollback |
## Cutover Gates
1. Capability contract and security tests pass.
2. Data mapping/provenance and retention are documented.
3. Shadow or dual-run shows no unauthorized access, loss, or duplicate effects.
4. Operator runbook and rollback are exercised.
5. Channel/provider binding changes are reversible without schema rollback.
6. Legacy capability is disabled only after a defined soak period and evidence review.

View File

@@ -0,0 +1,46 @@
# Mission Manifest — Tess Interaction Agent
## Mission
- **ID:** tess-20260712
- **Issue:** #706
- **Branch:** `feat/tess-interaction-agent`
- **Phase:** Execution
- **Current Milestone:** TESS-M1 — Runtime contracts and security foundation
- **Progress:** 0 / 5 delivery milestones complete
- **Status:** active
- **Owner:** Mosaic orchestrator; Mos is coordinating fleet authority
- **Source PRD:** `docs/PRD.md``TESS-*` requirements
- **Scratchpad:** `docs/scratchpads/tess-20260712.md`
## Mission Statement
Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction agent for Discord and CLI, with safe visibility/control of Mosaic fleet and transitional Hermes capabilities, while Mos remains the coding/general orchestration authority.
## Invariants
1. Mosaic is the enterprise AI hub; Hermes is a reference migration adapter.
2. Gateway is the single API surface.
3. Mos owns coding/general fleet orchestration; Tess owns human interaction, visibility, mediation, and migration access.
4. Runtime, transport, channel, memory, and external-agent integrations are replaceable adapters.
5. No source task completes before merged PR, terminal-green CI, independent review, and linked task/issue closure.
## Milestones
| ID | Issue | Name | Status | Exit gate |
| ------- | ----- | ------------------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete |
## Success Criteria
All `AC-TESS-*` criteria in `docs/PRD.md` are mapped to reproducible evidence. The final operational test must prove Discord + CLI session continuity, fleet/Mos coordination, authorized Hermes transition capabilities, denial/audit paths, restart recovery, and rollback.
## Session History
| Session | Date | Runtime | Outcome |
| ------- | ---------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| S1 | 2026-07-12 | Hermes / GPT-5.6 Sol | User commission captured; Mosaic/OpenViking/session/code archaeology completed; issue #706 created; PRD and task control plane initialized. |

34
docs/tess/TASKS.md Normal file
View File

@@ -0,0 +1,34 @@
# Tasks — Tess Interaction Agent
> Mission: `tess-20260712` · Issue: #706 · PRD requirements: `TESS-*`
> Orchestrator is sole writer. Workers must not modify this file.
> `repo` contains one or more comma-separated repository-relative roots; every listed root must exist before dispatch.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
| TESS-PLAN-001 | done | Finalize PRD, architecture, authority boundary, threat model, migration inventory, and verification matrix | #706 | sonnet | docs, packages/types, apps/gateway | feat/tess-interaction-agent | — | 22K | Independent gate PASS after two remediation rounds; completion effective when planning PR merges |
| TESS-M1-SEC-001 | not-started | Enforce command scopes/roles and durable exact-action approval for privileged/destructive commands | #707 | codex | apps/gateway | fix/tess-command-authz | TESS-PLAN-001 | 25K | TESS-SEC-002; security TDD |
| TESS-M1-SEC-002 | not-started | Enforce owner/tenant binding on session list/read/attach/send/terminate across REST and WS | #707 | codex | apps/gateway | fix/tess-session-ownership | TESS-PLAN-001 | 30K | TESS-SEC-003; security TDD |
| TESS-M1-SEC-003 | not-started | Bind MCP actor/tenant to authenticated context and add per-tool scopes | #707 | codex | apps/gateway | fix/tess-mcp-identity | TESS-PLAN-001 | 22K | TESS-SEC-004; security TDD |
| TESS-M1-SEC-004 | not-started | Add authenticated Discord service ingress, allowlists, correlation and replay protection | #707 | codex | plugins/discord, apps/gateway | fix/tess-discord-ingress | TESS-PLAN-001 | 28K | TESS-SEC-005; security TDD |
| TESS-M1-SEC-005 | not-started | Redact/classify secret and PII before persistence/egress; harden provider login flow | #707 | codex | apps/gateway, packages/log | fix/tess-redaction | TESS-PLAN-001 | 28K | TESS-SEC-006; seeded canary tests |
| TESS-M1-SEC-006 | not-started | Scope session GC/retention or separate authorized global retention job | #707 | codex | apps/gateway, packages/log | fix/tess-session-gc-scope | TESS-PLAN-001 | 18K | TESS-SEC-009; isolation TDD |
| TESS-M1-001 | not-started | Define AgentRuntimeProvider, capabilities, session tree, normalized stream events/errors, attach semantics | #707 | codex | packages/types, packages/agent | feat/tess-runtime-contract | TESS-PLAN-001 | 25K | TESS-ARP-001, TESS-TRN-001; contract TDD |
| TESS-M1-002 | not-started | Implement provider registry/service with immutable actor scope, approval, audit and correlation boundaries | #707 | codex | apps/gateway, packages/agent | feat/tess-provider-registry | TESS-M1-001,TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003 | 30K | TESS-SEC-001..004,007; security TDD |
| TESS-M1-003 | not-started | Implement tmux/fleet runtime provider and safe attach/message/terminate capability policy | #707 | codex | packages/mosaic, packages/agent | feat/tess-fleet-provider | TESS-M1-002 | 30K | TESS-FLT-001; exact target/identity tests |
| TESS-M1-OBS-001 | not-started | Implement correlation propagation, structured runtime/provider/tool audit, health/readiness and safe effective-policy status | #707 | codex | apps/gateway, packages/agent, packages/log | feat/tess-observability | TESS-M1-002 | 24K | TESS-OBS-001; no credential material |
| TESS-M1-V | not-started | Independent architecture/security review and complete contract/abuse-suite verification | #707 | sonnet | apps/gateway, packages/agent, packages/log, plugins/discord | review/tess-m1 | TESS-M1-SEC-001,TESS-M1-SEC-002,TESS-M1-SEC-003,TESS-M1-SEC-004,TESS-M1-SEC-005,TESS-M1-SEC-006,TESS-M1-003,TESS-M1-OBS-001 | 20K | Gate M2 |
| TESS-M2-001 | not-started | Add Tess roster/profile/service pinned to GPT-5.6 Sol high with fail-fast config and observable effective policy | #708 | codex | packages/mosaic/framework | feat/tess-pi-service | TESS-M1-V | 22K | TESS-PI-001; explicit AC-TESS-03 test |
| TESS-M2-002 | not-started | Implement durable session identity, inbox/outbox, approval, checkpoint, handoff, compaction and restart recovery | #708 | codex | apps/gateway, packages/agent, packages/db | feat/tess-durable-state | TESS-M2-001 | 38K | TESS-STA-001, TESS-SEC-007..008; recovery TDD |
| TESS-M2-V | not-started | Clean-host Pi launch plus model/policy status and restart/compaction/duplicate-side-effect verification | #708 | sonnet | apps/gateway/src/__tests__/integration, packages/mosaic/src | review/tess-m2 | TESS-M2-002 | 18K | Gate M3; AC-TESS-03/06 |
| TESS-M3-001 | not-started | Bind dedicated Tess Discord channel with streaming, threads, attachments, pairing/RBAC and approvals | #709 | codex | plugins/discord, apps/gateway | feat/tess-discord | TESS-M2-V,TESS-M1-SEC-004 | 35K | TESS-DSC-001 |
| TESS-M3-002 | not-started | Implement `mosaic tess` chat/status/sessions/tree/attach/send/stop/health/recover CLI | #709 | codex | packages/mosaic | feat/tess-cli | TESS-M2-V | 30K | TESS-CLI-001 |
| TESS-M3-V | not-started | Discord+CLI same-session E2E, denial/approval tests, and operator-flow review | #709 | sonnet | apps/gateway/src/__tests__/integration, plugins/discord, packages/mosaic/src | review/tess-m3 | TESS-M3-001,TESS-M3-002 | 20K | Gate M4 |
| TESS-M4-001 | not-started | Implement Mos coordination handoff/observe/result contract with authority-boundary tests | #710 | codex | packages/coord, apps/gateway | feat/tess-mos-coordination | TESS-M3-V | 25K | TESS-MOS-001 |
| TESS-M4-002 | not-started | Implement transitional Hermes runtime/capability adapter | #710 | codex | packages/agent, apps/gateway | feat/tess-hermes-adapter | TESS-M3-V | 40K | TESS-HRM-001; no legacy schema in core contracts |
| TESS-M4-003 | not-started | Implement memory/retrieval, state/inbox, runtime bootstrap, fleet diagnostics and GitOps plugin foundations | #710 | codex | packages/memory, packages/agent, packages/mosaic | feat/tess-operator-plugins | TESS-M3-V | 40K | TESS-MEM-001, TESS-PLG-001 |
| TESS-M4-V | not-started | Cross-provider capability, privacy, authority and failure-path qualification | #710 | sonnet | apps/gateway/src/__tests__/integration, packages/agent | review/tess-m4 | TESS-M4-001,TESS-M4-002,TESS-M4-003 | 22K | Gate M5 |
| TESS-M5-001 | not-started | Implement Matrix/native runtime provider behind common contracts and parity suite | #711 | codex | packages/mosaic, packages/agent | feat/tess-matrix-provider | TESS-M4-V | 30K | TESS-TRN-001 |
| TESS-M5-002 | not-started | Complete migration inventory, cutover, rollback, retention and deprecation evidence | #711 | sonnet | docs/tess | feat/tess-migration-docs | TESS-M4-V | 18K | TESS-MIG-001 |
| TESS-M5-003 | not-started | Complete OpenAPI, user/admin/developer/plugin/operations docs and checklist | #711 | codex | docs | feat/tess-docs | TESS-M5-001,TESS-M5-002 | 22K | Documentation hard gate |
| TESS-M5-V | not-started | Full baseline, contract, integration, Discord/CLI E2E, security review, recovery drill and rollback qualification | #711 | sonnet | apps/gateway, packages/agent, plugins/discord, packages/mosaic | review/tess-final | TESS-M5-003 | 35K | Maps AC-TESS-01..11 to evidence |

46
docs/tess/THREAT-MODEL.md Normal file
View File

@@ -0,0 +1,46 @@
# Tess Threat Model
## Assets and Trust Boundaries
Assets: operator identity, tenant/project data, agent sessions, fleet control, approvals, credentials, memories, tool outputs, audit evidence, and provider transports.
Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service identity, gateway→Pi/provider, Tess→Mos/fleet, Tess→Hermes, MCP→gateway, persistence, and tmux/Matrix transports.
## Threat Matrix
| ID | Severity | Threat | Required control | Required verification |
| ----- | -------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
| TM-01 | critical | Client invokes admin/system command without role | Server-side scope/role enforcement in executor; durable approval for privileged/destructive commands | Authenticated non-admin and forged-scope tests deny and audit |
| TM-02 | critical | Cross-user/tenant list, attach, send, or terminate by guessed session ID | Owner/tenant binding on every session operation; admin override is explicit and audited | Cross-tenant matrix for REST, WS, CLI, Discord and provider methods |
| TM-03 | high | MCP caller supplies another `userId` | Remove actor IDs from schemas; derive actor/tenant from authenticated context; per-tool scopes | Forged actor/tool calls deny; no victim data returned |
| TM-04 | high | Discord ingress impersonates user/channel or bypasses gateway auth | Service-to-service identity, guild/channel/user allowlists, signed/correlated envelope, replay protection | Invalid service identity, unlisted IDs, replayed message IDs all deny |
| TM-05 | high | Secrets/PII leak in chat, auth links, tool args, logs, memory, or DB | Redact before persistence/egress; DM/out-of-band auth flow; short-lived hashed token state; output classification | Seeded secret/PII canary absent from durable stores/logs/public channel |
| TM-06 | high | Prompt/tool injection escalates from content to privileged action | Treat messages/files/tool output as untrusted data; structured proposals only; allowlisted tools; approval binds exact action digest | Injection corpus cannot invoke unapproved tools or alter authority |
| TM-07 | high | Approval forged, replayed, or applied to modified action | One-time approval with actor, tenant, action digest, expiry, correlation and consumption record | Forged/replayed/expired/mutated approvals deny and audit |
| TM-08 | medium | Restart causes message loss or duplicate side effects | Durable inbox/outbox/checkpoint; idempotency keys; transactional state transitions; bounded replay | Kill/restart at each state transition; exactly-once effect or safe dedupe |
| TM-09 | medium | Session GC/retention crosses tenant/session scope | Session/user-scoped GC or separately authorized global retention job | GC one session; unrelated logs/memory remain unchanged |
| TM-10 | high | tmux/Matrix transport target or identity spoofing | Exact target/socket binding, peer identity verification, Matrix whoami, authenticated transport metadata | Wrong socket/peer/room/identity refuses delivery/attach |
| TM-11 | medium | Hermes adapter exposes unsupported or broader legacy powers | Capability negotiation, default deny, normalized scopes, adapter sandbox/timeouts | Unsupported and over-scoped operations fail closed |
| TM-12 | medium | Tess competes with Mos or bypasses orchestration gates | Authority policy and correlated Mos handoff; no Tess worker-claim capability by default | Coding/decomposition intent produces handoff, not direct claim |
## Security Invariants
1. Authentication is not authorization; every command/tool/provider operation is authorized server-side.
2. Actor, tenant, roles, and channel bindings come only from authenticated gateway context.
3. No client-provided session ID grants ownership or attachment.
4. No privileged action executes without a matching, unexpired, one-time approval when policy requires it.
5. Redaction occurs before persistence and before channel egress.
6. Every externally caused operation is replay-safe and correlated.
7. Provider capability absence is a denial, not an invitation to shell around it.
## Existing Findings That Block Tess
- Command executor lacks server-side enforcement for declared scopes.
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
- MCP schemas accept caller-supplied user identity.
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
- Chat/tool persistence lacks mandatory redaction.
- Sessions/pending Discord output are in-memory and not restart-safe.
- Session GC currently performs globally scoped promotion.
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.

View File

@@ -0,0 +1,30 @@
# Tess Verification Matrix
| Acceptance criterion | Requirements | Planned evidence | Gate |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
| AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
## Security Abuse Suite Minimum
- Role/scope matrix for every command and provider capability.
- Cross-tenant and cross-user session ID matrix across REST, WS, Discord, CLI, MCP, and providers.
- Discord service identity, guild/channel/user allowlist, replay, attachment, and mention/DM policy cases.
- Prompt/tool injection corpus and structured-proposal enforcement.
- Approval action-digest mutation, replay, expiry, tenant, and actor mismatch cases.
- Secret/PII canaries through message, attachment, tool args/output, logs, memory, audit, and error paths.
- Restart fault injection before/after enqueue, provider send, side effect, response persistence, and acknowledgement.
- Wrong tmux socket/target and Matrix identity/room/replay cases.
## Evidence Rules
Evidence must include command/test name, terminal result, CI run URL, PR/merge reference, environment, and artifact/log location. A worker self-report is not evidence until independently verified.

View File

@@ -30,6 +30,7 @@ export default tseslint.config(
'apps/gateway/vitest.config.ts',
'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',
'packages/mosaic/__tests__/*.ts',
'tools/federation-harness/*.ts',
],

View File

@@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
Agent hosts must provide the Python runtime shape that runtime agents and
Mosaic automation assume is present.
For Debian/Ubuntu hosts:
```bash
sudo apt-get update
# #561: bare python invocations from agents must resolve.
sudo apt-get install -y python3 python-is-python3
```
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
## Quick Start
```bash

View File

@@ -15,6 +15,22 @@ This guide covers how to bootstrap a project so AI agents (Claude, Codex, etc.)
7. Branching/merging is consistent: `branch -> main` via PR with squash-only merges
8. Steered-autonomy execution is enabled so agents can run end-to-end with escalation-only human intervention
## Agent Host Prerequisites
Agent hosts must provide the Python runtime shape that runtime agents and
Mosaic automation assume is present.
For Debian/Ubuntu hosts:
```bash
sudo apt-get update
# #561: bare python invocations from agents must resolve.
sudo apt-get install -y python3 python-is-python3
```
For non-Debian hosts, install the equivalent Python 3 runtime and ensure
`/usr/bin/python` resolves to `python3` (for example, via a managed symlink).
## Quick Start
```bash

View File

@@ -15,13 +15,23 @@
#
# After loading, service-specific env vars are exported.
# Run `load_credentials --help` for details.
#
# Resolution order (first match wins):
# 1. $MOSAIC_CREDENTIALS_FILE (explicit override — never second-guessed)
# 2. $HOME/.config/mosaic/credentials.json
# 3. /etc/mosaic/credentials.json (host-level fallback)
# The /etc fallback exists for HOME-redirected profile environments, where
# $HOME points at a per-profile directory that has no credentials file.
# Operators symlink /etc/mosaic/credentials.json to the host's canonical
# file once, instead of exporting MOSAIC_CREDENTIALS_FILE per invocation.
if [[ -z "${MOSAIC_CREDENTIALS_FILE:-}" ]]; then
for _cand in "$HOME/.config/mosaic/credentials.json"; do
for _cand in "$HOME/.config/mosaic/credentials.json" "/etc/mosaic/credentials.json"; do
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
done
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
fi
export MOSAIC_CREDENTIALS_FILE
_mosaic_require_jq() {
if ! command -v jq &>/dev/null; then

View File

@@ -86,7 +86,16 @@ gitea_url_matches_host() {
get_gitea_service_for_host() {
local host="$1"
local cred_file="${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
local cred_file="${MOSAIC_CREDENTIALS_FILE:-}"
if [[ -z "$cred_file" ]]; then
# Same resolution chain as _lib/credentials.sh: profile HOME, then
# host-level /etc only if it exists; neither existing keeps the
# $HOME default (matches the lib's final := fallback).
cred_file="$HOME/.config/mosaic/credentials.json"
if [[ ! -f "$cred_file" && -f /etc/mosaic/credentials.json ]]; then
cred_file="/etc/mosaic/credentials.json"
fi
fi
case "$host" in
git.mosaicstack.dev)
@@ -231,6 +240,33 @@ 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

View File

@@ -33,7 +33,7 @@ Examples:
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
$(basename "$0") -i 42 -a @me
EOF
exit 1
exit "${1:-1}"
}
# Parse arguments
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -12,6 +12,7 @@ TITLE=""
BODY=""
LABELS=""
MILESTONE=""
INTERACTIVE=false
# get_remote_host and get_gitea_token are provided by detect-platform.sh
@@ -66,13 +67,15 @@ 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
exit "${1:-1}"
}
# Parse arguments
@@ -94,8 +97,12 @@ while [[ $# -gt 0 ]]; do
MILESTONE="$2"
shift 2
;;
-i|--interactive)
INTERACTIVE=true
shift
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2
@@ -104,6 +111,13 @@ 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
@@ -127,6 +141,11 @@ 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")

View File

@@ -36,7 +36,7 @@ Examples:
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
EOF
exit 1
exit "${1:-1}"
}
# Parse arguments
@@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do
shift 2
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -37,7 +37,7 @@ Examples:
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
EOF
exit 1
exit "${1:-1}"
}
# Parse arguments
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -86,7 +86,7 @@ Examples:
$(basename "$0") -i 42 -b "Implements the feature described in #42"
$(basename "$0") -t "WIP: New feature" --draft
EOF
exit 1
exit "${1:-1}"
}
# Parse arguments
@@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2
@@ -183,6 +183,11 @@ 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")

View File

@@ -34,7 +34,7 @@ Examples:
$(basename "$0") -s merged -a username # Merged PRs by user
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
EOF
exit 1
exit "${1:-1}"
}
# Parse arguments
@@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do
shift 2
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -35,7 +35,7 @@ Examples:
$(basename "$0") -n 42 -d # Squash merge and delete branch
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
EOF
exit 1
exit "${1:-1}"
}
# Parse arguments
@@ -63,7 +63,7 @@ while [[ $# -gt 0 ]]; do
shift
;;
-h|--help)
usage
usage 0
;;
*)
echo "Unknown option: $1" >&2

View File

@@ -45,6 +45,11 @@ 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

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Regression harness for #701: -h/--help must exit 0, bad args must still exit nonzero.
#
# Covers the 7 wrappers whose usage() previously hard-coded `exit 1`, so every
# --help invocation exited nonzero and logged a phantom isError across fleet lanes.
# Asserts, per wrapper:
# 1. `--help` exits 0 and prints usage.
# 2. `-h` exits 0 and prints usage.
# 3. A genuine unknown flag still exits nonzero (usage() default path untouched).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WRAPPERS=(
issue-assign.sh
issue-create.sh
issue-list.sh
milestone-create.sh
pr-create.sh
pr-list.sh
pr-merge.sh
)
fail=0
for wrapper in "${WRAPPERS[@]}"; do
path="$SCRIPT_DIR/$wrapper"
if ! output=$(bash "$path" --help 2>&1); then
echo "FAIL: $wrapper --help exited nonzero" >&2
fail=1
elif [[ "$output" != Usage:* ]]; then
echo "FAIL: $wrapper --help did not print usage" >&2
fail=1
fi
if ! bash "$path" -h >/dev/null 2>&1; then
echo "FAIL: $wrapper -h exited nonzero" >&2
fail=1
fi
if bash "$path" --this-is-not-a-real-flag >/dev/null 2>&1; then
echo "FAIL: $wrapper accepted an unknown flag (should have exited nonzero)" >&2
fail=1
fi
done
if [[ "$fail" -eq 0 ]]; then
echo "help-exit-code regression passed (7/7 wrappers)"
fi
exit "$fail"

View File

@@ -55,6 +55,11 @@ 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

View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
REPO_DIR="$WORK_DIR/repo"
BIN_DIR="$WORK_DIR/bin"
LOG_FILE="$WORK_DIR/calls.log"
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
rm -rf "$WORK_DIR"
mkdir -p "$REPO_DIR" "$BIN_DIR"
git -C "$REPO_DIR" init -q
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
cat > "$CREDENTIALS_FILE" <<'JSON'
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
JSON
cat > "$BIN_DIR/tea" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
if [[ "$*" == "login list --output json" ]]; then
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
exit 0
fi
if [[ "${1:-}" == "api" ]]; then
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
echo 'GetUserByName: stale configured user' >&2
exit 1
fi
printf '%s\n' '{"login":"current-user"}'
exit 0
fi
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
exit 0
SH
cat > "$BIN_DIR/curl" <<'SH'
#!/usr/bin/env bash
set -euo pipefail
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
printf '%s\n' '{"number":703}'
SH
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
run_wrapper() {
(
cd "$REPO_DIR"
PATH="$BIN_DIR:$PATH" \
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
MOSAIC_TEST_LOG="$LOG_FILE" \
"$@"
)
}
: > "$LOG_FILE"
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
# Explicit values take precedence in interactive mode: no title input is
# supplied, but the wrapper still creates the issue with the explicit title.
: > "$LOG_FILE"
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
if grep -q -- 'tea issue create' "$LOG_FILE"; then
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
: > "$LOG_FILE"
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
if grep -q -- 'tea pr create' "$LOG_FILE"; then
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
exit 1
fi
echo 'issue-create interactive/auth regression harness passed'

View File

@@ -87,6 +87,10 @@ message crosses the wire as base64 (`-b`) to avoid all shell-quoting hazards.
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
- `auto-submit-drafts.sh` — watchdog that flushes stable unsubmitted prompt
drafts on a coordinator pane (default target `mos-claude`); run it as a
long-lived process alongside the coordinator session.
- `agent-send.test.sh` — regression + grammar lock for `agent-send.sh`.
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
## Distribution

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# auto-submit-drafts.sh — watchdog for Claude Code panes that receive channel
# messages but leave them as unsubmitted prompt drafts. Intended for Mos only.
set -uo pipefail
TARGET="${1:-mos-claude}"
INTERVAL="${INTERVAL:-2}"
STABLE_SECONDS="${STABLE_SECONDS:-4}"
LOG_PREFIX="[auto-submit-drafts:$TARGET]"
last_prompt=""
first_seen=0
prompt_text() {
tmux capture-pane -t "$TARGET" -p 2>/dev/null | python3 -c '
import sys, re
lines = sys.stdin.read().splitlines()
idx = None
for i in range(len(lines)-1, -1, -1):
if "" in lines[i]:
idx = i
break
if idx is None:
raise SystemExit
parts = []
after = lines[idx].split("", 1)[1]
parts.append(after)
for line in lines[idx+1:]:
# Stop at Claude Code separator/border lines.
if "─" in line or "╰" in line or "╭" in line:
break
s = line.replace("\u00a0", " ")
s = re.sub(r"[\x00-\x1f\x7f]", "", s).strip()
if s:
parts.append(s)
text = " ".join(parts).replace("\u00a0", " ")
text = re.sub(r"[\x00-\x1f\x7f]", "", text).strip()
print(text)
'
}
while true; do
if ! tmux has-session -t "$TARGET" 2>/dev/null; then
echo "$LOG_PREFIX target missing; waiting" >&2
sleep "$INTERVAL"
last_prompt=""
first_seen=0
continue
fi
current="$(prompt_text || true)"
now="$(date +%s)"
if [[ -z "$current" ]]; then
last_prompt=""
first_seen=0
sleep "$INTERVAL"
continue
fi
if [[ "$current" != "$last_prompt" ]]; then
last_prompt="$current"
first_seen="$now"
sleep "$INTERVAL"
continue
fi
age=$(( now - first_seen ))
if (( age >= STABLE_SECONDS )); then
echo "$LOG_PREFIX submitting stable draft after ${age}s: ${current:0:120}" >&2
tmux send-keys -t "$TARGET" C-j
sleep 0.8
tmux send-keys -t "$TARGET" C-m
sleep 2
last_prompt=""
first_seen=0
else
sleep "$INTERVAL"
fi
done

View File

@@ -77,10 +77,20 @@ snippet=$(printf '%s' "$MSG" | tr '\n' ' ' | tr -s ' ' | sed 's/[^[:print:]]//g'
# 1) Paste the body as a bracketed paste so multi-line content does not submit
# line-by-line. load-buffer/paste-buffer is far safer than `send-keys -l`.
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b __mosaic_send -
# Buffer name MUST be unique per invocation: concurrent senders on the shared
# tmux server race a fixed name (load overwrites load, -d deletes underneath),
# cross-delivering or dropping messages — bit the fleet on the 2026-07-09
# simultaneous restart (briefs swapped between sessions).
BUF="__mosaic_send_$$_$(date +%s%N)"
printf '%s' "$MSG" | "${tmux_cmd[@]}" load-buffer -b "$BUF" -
# -p = bracketed paste when the client supports it; fall back if not.
"${tmux_cmd[@]}" paste-buffer -d -p -b __mosaic_send -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
"${tmux_cmd[@]}" paste-buffer -d -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|| "${tmux_cmd[@]}" paste-buffer -d -b "$BUF" -t "$EFFECTIVE_TARGET" \
|| "${tmux_cmd[@]}" delete-buffer -b "$BUF" 2>/dev/null
# ^ -d deletes the buffer only on a SUCCESSFUL paste; if both attempts fail
# (e.g. the target vanished since the liveness check), delete explicitly —
# named buffers are exempt from tmux's buffer-limit eviction, so orphans
# would otherwise accumulate forever.
sleep 0.5
# 2) Submit, then verify; flush with another Enter if it is still a draft.

View File

@@ -47,4 +47,32 @@ if capture_default | grep -qF "agent socket hello"; then
fail "agent-send.sh leaked named-socket message to default tmux server"
fi
# Concurrency: parallel senders on one server must not cross-deliver or drop.
# Locks the unique-per-invocation paste buffer (a fixed buffer name raced:
# load overwrote load, -d deleted underneath — messages swapped between panes).
CONC_N=5
for i in $(seq 1 "$CONC_N"); do
tmux -L "$SOCKET" new-session -d -s "conc-$i" -c "$TMPDIR" 'bash --noprofile --norc -i'
done
pids=()
for i in $(seq 1 "$CONC_N"); do
"$SEND_MESSAGE" -L "$SOCKET" -t "=conc-$i" -m "CONCPAYLOAD-${i}-END" >/dev/null &
pids+=($!)
done
for pid in "${pids[@]}"; do
wait "$pid" || fail "concurrent send-message.sh invocation exited non-zero"
done
sleep 0.2
for i in $(seq 1 "$CONC_N"); do
pane=$(tmux -L "$SOCKET" capture-pane -t "=conc-$i:0.0" -p)
printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${i}-END" \
|| fail "concurrent send dropped payload for pane conc-$i"
for j in $(seq 1 "$CONC_N"); do
[ "$j" = "$i" ] && continue
if printf '%s' "$pane" | grep -qF "CONCPAYLOAD-${j}-END"; then
fail "concurrent send cross-delivered payload $j to pane conc-$i"
fi
done
done
echo "ok - named tmux socket send tools"

View File

@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
acquireRestartLock,
addAgentToRoster,
buildAgentSendCommand,
buildAgentWatchAttachCommand,
@@ -45,6 +46,8 @@ import {
removeAgentFromRoster,
resolveFleetPaths,
resolvePresetFilename,
restartLockPath,
RESTART_LOCK_STALE_MS,
RUNTIME_ACCEPTABLE_COMMANDS,
serializeRosterToYaml,
VERIFY_DEFAULT_TIMEOUT_MS,
@@ -678,6 +681,364 @@ describe('fleet command construction', () => {
}
});
it('waits for an in-flight restart to clear before relaunching (re-entry guard)', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
// Simulate another `mosaic fleet restart` process mid-teardown: a fresh lock
// (recent timestamp, so it is NOT treated as stale) already held.
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
await writeFile(lockPath, `4242\n${Date.now()}\n`);
const events: string[] = [];
const runner: CommandRunner = async (command, args) => {
events.push(`run:${args[args.length - 1]}`);
return { stdout: '', stderr: '', exitCode: 0 };
};
// The injected sleep stands in for time passing while we wait; the in-flight
// restart "finishes" (releases its lock) after the first poll.
let sleeps = 0;
const sleepFn: SleepFn = async () => {
sleeps += 1;
events.push(`sleep:${sleeps}`);
await rm(lockPath, { force: true });
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
// It must have waited at least once before issuing any systemctl restart.
expect(sleeps).toBeGreaterThan(0);
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
const firstRun = events.findIndex((e) => e.startsWith('run:'));
expect(firstSleep).toBeGreaterThanOrEqual(0);
expect(firstRun).toBeGreaterThan(firstSleep);
// And it still performs the full restart once the lock clears.
expect(events).toContain('run:mosaic-tmux-holder.service');
expect(events).toContain('run:mosaic-agent@coder0.service');
// The lock is released after the restart completes.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('breaks a stale restart lock and proceeds without waiting', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
// A lock left behind by a crashed owner: timestamp older than the stale window.
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n`);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const sleepFn = vi.fn<SleepFn>(async () => {});
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
// Stale lock is broken immediately — no waiting.
expect(sleepFn).not.toHaveBeenCalled();
expect(calls).toEqual([
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
]);
// The stale lock is gone once the restart completes.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('releases the restart lock so a subsequent restart is not blocked', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
const calls: string[][] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args]);
return { stdout: '', stderr: '', exitCode: 0 };
};
const sleepFn = vi.fn<SleepFn>(async () => {});
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart']);
// Two sequential restarts both run fully and neither has to wait.
expect(sleepFn).not.toHaveBeenCalled();
expect(calls).toEqual([
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
['systemctl', '--user', 'restart', 'mosaic-tmux-holder.service'],
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
]);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('guards the single-agent restart path behind the in-flight restart lock', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');
await mkdir(join(home, 'fleet'), { recursive: true });
await writeFile(
rosterPath,
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
'\n',
),
);
// A full restart is mid-flight (lock held); a single-agent restart re-enters.
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
await writeFile(lockPath, `4242\n${Date.now()}\n`);
const events: string[] = [];
const runner: CommandRunner = async (command, args) => {
events.push(`run:${args[args.length - 1]}`);
return { stdout: '', stderr: '', exitCode: 0 };
};
let sleeps = 0;
const sleepFn: SleepFn = async () => {
sleeps += 1;
events.push(`sleep:${sleeps}`);
await rm(lockPath, { force: true });
};
const program = new Command();
program.exitOverride();
registerFleetCommand(program, { runner, sleepFn, mosaicHome: home });
try {
await program.parseAsync(['node', 'mosaic', 'fleet', 'restart', 'coder0']);
// The single-agent restart waits for the in-flight restart before acting.
expect(sleeps).toBeGreaterThan(0);
const firstSleep = events.findIndex((e) => e.startsWith('sleep:'));
const firstRun = events.findIndex((e) => e.startsWith('run:'));
expect(firstSleep).toBeGreaterThanOrEqual(0);
expect(firstRun).toBeGreaterThan(firstSleep);
// Only the named agent is restarted; the holder is untouched.
expect(events).toContain('run:mosaic-agent@coder0.service');
expect(events).not.toContain('run:mosaic-tmux-holder.service');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('does not let a timed-out owner drop a lock another restart broke and re-owned', async () => {
const home = await tempDir();
const runDir = join(home, 'fleet', 'run');
await mkdir(runDir, { recursive: true });
const lockPath = restartLockPath(home);
const tokenOf = async (): Promise<string> => {
const raw = await readFile(lockPath, 'utf8');
return raw.split('\n')[2]?.trim() ?? '';
};
const sleepFn = vi.fn<SleepFn>(async () => {});
// R1 acquires the lock and begins a restart that then hangs.
const r1 = await acquireRestartLock(home, sleepFn);
const tokenR1 = await tokenOf();
expect(tokenR1).not.toBe('');
// The hung R1 leaves a stale lock: rewrite its timestamp into the past while
// preserving R1's token — exactly the on-disk state a stuck owner leaves.
await writeFile(lockPath, `4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\n${tokenR1}\n`);
// R2 re-enters, sees the stale lock, and atomically takes ownership.
const r2 = await acquireRestartLock(home, sleepFn);
const tokenR2 = await tokenOf();
expect(tokenR2).not.toBe(tokenR1);
expect(sleepFn).not.toHaveBeenCalled();
// R1 finally finishes and releases. It must NOT delete R2's lock — otherwise
// a third restart (R3) could acquire and interleave with R2 still running.
await r1.release();
expect(await tokenOf()).toBe(tokenR2);
// R2 releases cleanly and the lock is gone.
await r2.release();
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
await rm(home, { recursive: true, force: true });
});
it('lets only one of several concurrent breakers proceed past a stale lock', async () => {
const home = await tempDir();
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
// A stale lock left by a crashed owner: every concurrent re-entrant restart
// will judge it stale and try to break it at the same instant. Breaking must
// NOT grant ownership — only the atomic re-create may — so exactly one
// contender can ever hold the lock at a time. (The v2 fix wrote our own token
// during the break and read it back, so two breakers each saw their own token
// and BOTH proceeded; this guards that regression.)
await writeFile(
lockPath,
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
);
// Yielding sleep so a waiting contender lets the current owner finish and
// release before it re-contends, instead of spinning the microtask queue.
const sleepFn: SleepFn = async () => {
await new Promise((res) => setTimeout(res, 0));
};
let active = 0;
let maxActive = 0;
const tokens: string[] = [];
const tokenOf = async (): Promise<string> => {
const raw = await readFile(lockPath, 'utf8');
return raw.split('\n')[2]?.trim() ?? '';
};
// One "restart" = acquire the lock, do work in the critical section, release.
const restartOnce = async (): Promise<void> => {
const guard = await acquireRestartLock(home, sleepFn);
active += 1;
maxActive = Math.max(maxActive, active);
// Record the token we own while we hold it, then yield to interleave with
// any other contender that might (wrongly) believe it owns the lock too.
tokens.push(await tokenOf());
await new Promise((res) => setTimeout(res, 0));
active -= 1;
await guard.release();
};
try {
// Three breakers race the single stale lock simultaneously.
await Promise.all([restartOnce(), restartOnce(), restartOnce()]);
// Mutual exclusion held: never two owners at once despite concurrent breaks.
expect(maxActive).toBe(1);
// Each acquire owned with its own distinct token — no two ever shared it.
expect(new Set(tokens).size).toBe(3);
// The lock is fully released at the end.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('lets exactly one of two breakers take over a stale lock while the other waits', async () => {
const home = await tempDir();
const lockPath = restartLockPath(home);
await mkdir(dirname(lockPath), { recursive: true });
// A single stale lock both contenders will judge stale at the same instant.
// Every transition runs under the registry mutex, so only one may take the
// lock over; the other must observe a now-fresh owner and WAIT/re-evaluate
// rather than also taking over. (A content-blind clobber let both believe
// they owned it — this asserts the mutex-gated CAS takeover instead.)
await writeFile(
lockPath,
`4242\n${Date.now() - RESTART_LOCK_STALE_MS - 1_000}\nstale-owner-token\n`,
);
// Barrier the winner holds against until the loser has observed the lock
// fresh and waited at least once — forcing the exact interleaving where one
// proceeds while the other waits, deterministically rather than by timing.
let resolveLoserWaited: () => void = () => {};
const loserWaited = new Promise<void>((res) => {
resolveLoserWaited = res;
});
let sleeps = 0;
const sleepFn: SleepFn = async () => {
sleeps += 1;
resolveLoserWaited();
await new Promise((res) => setTimeout(res, 0));
};
let active = 0;
let maxActive = 0;
const tokens: string[] = [];
const tokenOf = async (): Promise<string> => {
const raw = await readFile(lockPath, 'utf8');
return raw.split('\n')[2]?.trim() ?? '';
};
let firstOwner = true;
const restartOnce = async (): Promise<void> => {
const guard = await acquireRestartLock(home, sleepFn);
active += 1;
maxActive = Math.max(maxActive, active);
tokens.push(await tokenOf());
if (firstOwner) {
// Winner: keep holding the lock until the loser has waited once, so the
// loser is guaranteed to see a FRESH owner (not the stale one) and back
// off — proving it could not also take over.
firstOwner = false;
await loserWaited;
} else {
await new Promise((res) => setTimeout(res, 0));
}
active -= 1;
await guard.release();
};
try {
// Exactly two breakers race the single stale lock.
await Promise.all([restartOnce(), restartOnce()]);
// Mutual exclusion: never two owners at once (if both took over the stale
// lock, this would be 2).
expect(maxActive).toBe(1);
// Both eventually owned, each with its own distinct token.
expect(new Set(tokens).size).toBe(2);
// The loser observed the winner's fresh lock and waited — it did NOT also
// take over the stale lock.
expect(sleeps).toBeGreaterThanOrEqual(1);
// The lock is fully released at the end.
await expect(readFile(lockPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('attempts every agent and the holder during fleet stop even when an agent stop fails', async () => {
const home = await tempDir();
const rosterPath = join(home, 'fleet', 'roster.yaml');

View File

@@ -1,5 +1,16 @@
import { constants } from 'node:fs';
import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import {
access,
chmod,
copyFile,
mkdir,
open,
readFile,
stat,
unlink,
writeFile,
} from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -533,6 +544,295 @@ export function buildFleetServiceCommand(action: FleetServiceAction, agentName?:
return ['systemctl', '--user', action, service];
}
/** Poll interval (ms) while waiting for an in-flight restart's lock to clear. */
export const RESTART_LOCK_POLL_INTERVAL_MS = 250;
/**
* Maximum time (ms) a re-entrant restart waits for the in-flight restart to
* finish before it breaks the lock and proceeds anyway. A bound is required so
* a crashed holder of the lock can never deadlock the fleet permanently.
*/
export const RESTART_LOCK_MAX_WAIT_MS = 30_000;
/**
* Age (ms) past which a restart lock is treated as stale (its owner died
* without releasing it) and is broken immediately rather than waited on.
*/
export const RESTART_LOCK_STALE_MS = 60_000;
/**
* Resolves the path of the cross-process restart lock for a given Mosaic home.
* Kept strictly under `<mosaicHome>/fleet/run` (not the heartbeat env override)
* so the lock is scoped to the same fleet the restart acts on.
*/
export function restartLockPath(mosaicHome: string): string {
return join(mosaicHome, 'fleet', 'run', 'restart.lock');
}
/** A held restart lock; `release()` removes the lock file iff we still own it. */
interface RestartGuard {
release(): Promise<void>;
}
/** Lock-file contents: pid (informational), timestamp, and a unique owner token. */
function formatRestartLockContent(token: string): string {
return `${process.pid}\n${Date.now()}\n${token}\n`;
}
/**
* Reads the owner token (line 3) from a lock file, or null if the file is
* missing/unreadable/tokenless. The token is what makes release and break
* ownership-safe: a process only ever acts on a lock whose token matches its own.
*/
async function readRestartLockToken(lockPath: string): Promise<string | null> {
let raw: string;
try {
raw = await readFile(lockPath, 'utf8');
} catch {
return null;
}
const token = raw.split('\n')[2]?.trim();
return token ? token : null;
}
/**
* Returns true when a lock's contents are stale: older than RESTART_LOCK_STALE_MS,
* or unparseable (a corrupt or partially written lock left by a crashed owner).
*/
function isRestartLockContentStale(raw: string, now: number): boolean {
const stampLine = raw.split('\n')[1] ?? '';
const stamp = Number.parseInt(stampLine.trim(), 10);
if (!Number.isFinite(stamp)) {
return true;
}
return now - stamp >= RESTART_LOCK_STALE_MS;
}
/**
* Path of the short-lived registry mutex that guards EVERY transition of the
* restart lock (acquire, release, takeover). Held only across a few filesystem
* ops — never across the restart itself — so contention clears in microseconds.
*/
function restartMutexPath(lockPath: string): string {
return `${lockPath}.mutex`;
}
/** Brief back-off between registry-mutex acquisition attempts (held microseconds). */
const RESTART_MUTEX_RETRY_MS = 20;
/**
* Staleness for the internal mutex / reclaim locks, judged by the file's mtime
* rather than its CONTENT. `open(path, 'wx')` creates the inode (with a fresh
* mtime) before any token/timestamp is written into it, so a content-based check
* would momentarily see that empty file as corrupt-and-stale and could reap a
* lock another contender is still acquiring. mtime is set atomically at creation,
* so a just-created lock always reads as live; only a lock whose holder died and
* stopped touching it ages past the threshold. These locks are never held across
* the restart itself (only a couple of filesystem ops), so any mtime this old can
* belong only to a dead holder.
*/
async function isRestartLockPathStale(path: string, now: number): Promise<boolean> {
try {
const info = await stat(path);
return now - info.mtimeMs >= RESTART_LOCK_STALE_MS;
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
return false; // Gone, not stale — the caller will re-contend.
}
return false; // Can't stat — treat as live and back off rather than reap.
}
}
/** Path of the reclaim lock that serializes reaping of a crashed-holder mutex. */
function restartReclaimPath(mutexPath: string): string {
return `${mutexPath}.reclaim`;
}
/**
* Reap a registry mutex left behind by a process that CRASHED mid-transition —
* one whose file has aged past RESTART_LOCK_STALE_MS. Because the mutex is held
* only for a couple of filesystem ops (no sleeps, never across the restart), a
* mutex this old can only belong to a dead holder.
*
* The reap removes the dead mutex but never CREATES/holds it — acquisition stays
* the single `open('wx')` create in {@link acquireRestartMutex}, so exactly one
* contender wins ownership no matter how the reap and acquires interleave. The
* removal is made conditional by a dedicated reclaim lock: while it is held the
* dead mutex is stable (its dead holder will never touch it, and no other
* reclaimer can race), so re-reading it and removing it only if it is STILL stale
* is a true compare — a live holder's fresh mutex is never removed. This closes
* the reclaim race a content-blind rename-and-restore left open (a third
* contender slipping into the gap while a fresh mutex was moved aside).
*/
async function reclaimStaleRestartMutex(mutexPath: string): Promise<void> {
const reclaimPath = restartReclaimPath(mutexPath);
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(reclaimPath, 'wx');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
// Someone is already reclaiming. If their reclaim lock is itself stale by
// mtime, its holder crashed mid-reap (the lock spans only a stat + unlink,
// microseconds) — clear it so a later pass can retry. Otherwise a live
// reclaimer has it; back off. Either way we do not reap the mutex this pass.
if (await isRestartLockPathStale(reclaimPath, Date.now())) {
await unlink(reclaimPath).catch(() => {});
}
return;
}
try {
// Re-check the mutex UNDER the reclaim lock and remove it only if it is STILL
// stale by mtime. A live holder's mutex is fresh and is left untouched; a dead
// holder's mutex is stable here (its holder is gone and no other reclaimer can
// race us), so this re-check is authoritative.
if (await isRestartLockPathStale(mutexPath, Date.now())) {
await unlink(mutexPath).catch(() => {});
}
} finally {
await handle.close();
await unlink(reclaimPath).catch(() => {});
}
}
/**
* Acquire the registry mutex, BLOCKING (with brief back-offs) until held, and
* return a token-gated release. This is the single point of mutual exclusion for
* the restart lock: acquire, release, and stale/timeout takeover all run under it,
* so "read the lock, then mutate it" is atomic — no acquirer, releaser, or breaker
* can ever interleave with another. A mutex left by a crashed holder is reclaimed
* once it ages past the stale threshold.
*/
async function acquireRestartMutex(
mutexPath: string,
token: string,
): Promise<RestartGuard['release']> {
for (;;) {
let handle: Awaited<ReturnType<typeof open>>;
try {
handle = await open(mutexPath, 'wx');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
// Staleness is judged by mtime, not content, so a mutex that exists but has
// not yet had its token written (the open-before-write window) reads as live
// and is never wrongly reaped.
if (!(await isRestartLockPathStale(mutexPath, Date.now()))) {
// A live holder has it — it will be gone in microseconds. Back off briefly.
await new Promise((resolve) => setTimeout(resolve, RESTART_MUTEX_RETRY_MS));
continue;
}
await reclaimStaleRestartMutex(mutexPath);
continue;
}
// We created the mutex. Populate it with our token; if writing fails, clean up
// our own file so we never leak an empty mutex that a peer would have to reap.
try {
await handle.writeFile(formatRestartLockContent(token));
await handle.close();
} catch (err) {
await handle.close().catch(() => {});
await unlink(mutexPath).catch(() => {});
throw err;
}
return async (): Promise<void> => {
if ((await readRestartLockToken(mutexPath)) !== token) return;
await unlink(mutexPath).catch(() => {});
};
}
}
/**
* Acquire the fleet restart lock, serializing concurrent `mosaic fleet restart`
* invocations across processes. Each restart tears the tmux holder (and the
* agent sessions inside it) down and back up; without this guard a re-entrant
* restart relaunches agents against a half-torn-down holder, which fails and
* tight-loops. A re-entrant caller waits for the in-flight restart to release
* the lock (clean shutdown settled) before proceeding, breaks a stale lock left
* by a crashed owner, and after RESTART_LOCK_MAX_WAIT_MS breaks the lock to
* avoid a permanent deadlock.
*
* Correctness rests on a single invariant: EVERY transition of the lock — taking
* a free lock, taking over a stale/timed-out one, and releasing — happens under
* the registry mutex. Because the check ("is the lock free / stale / fresh?") and
* the mutation that follows it both run while the mutex is held, they are atomic:
* no other acquirer, releaser, or breaker can slip in between. That is what makes
* takeover a true compare-and-swap rather than a content-blind clobber — a normal
* `open('wx')` acquirer cannot create a fresh lock in a gap, and the original
* owner's `release()` (also mutex-gated and token-checked) cannot drop a lock a
* breaker already took over. So no interleaving lets two restarts both own the
* lock and run concurrently.
*/
export async function acquireRestartLock(
mosaicHome: string,
sleepFn: SleepFn,
): Promise<RestartGuard> {
const token = randomUUID();
const lockPath = restartLockPath(mosaicHome);
const mutexPath = restartMutexPath(lockPath);
await mkdir(dirname(lockPath), { recursive: true });
const release = async (): Promise<void> => {
// Mutex-gated and token-gated: only remove the lock if it is still ours. If
// another caller took it over (after a stale/timeout break) the token no
// longer matches and we leave their lock intact.
const releaseMutex = await acquireRestartMutex(mutexPath, token);
try {
if ((await readRestartLockToken(lockPath)) === token) {
await unlink(lockPath).catch(() => {});
}
} finally {
await releaseMutex();
}
};
const deadline = Date.now() + RESTART_LOCK_MAX_WAIT_MS;
for (;;) {
let owned = false;
const releaseMutex = await acquireRestartMutex(mutexPath, token);
try {
// Read and (if appropriate) mutate the lock atomically under the mutex.
let current: string | null = null;
let absent = false;
try {
current = await readFile(lockPath, 'utf8');
} catch (readErr) {
if ((readErr as NodeJS.ErrnoException).code === 'ENOENT') {
absent = true;
} else {
current = null; // Unreadable/corrupt: treat as stale.
}
}
const now = Date.now();
if (absent) {
// Lock is free — take it.
await writeFile(lockPath, formatRestartLockContent(token));
owned = true;
} else {
const stale = current === null || isRestartLockContentStale(current, now);
const timedOut = now >= deadline;
if (stale || timedOut) {
process.stderr.write(
stale
? 'Breaking stale fleet restart lock.\n'
: `Timed out after ${RESTART_LOCK_MAX_WAIT_MS}ms waiting for the in-flight fleet ` +
'restart; breaking the lock.\n',
);
// Takeover is just an overwrite — safe because we hold the mutex, so no
// acquirer or releaser can touch the lock between our read and this write.
await writeFile(lockPath, formatRestartLockContent(token));
owned = true;
}
// else: a fresh restart owns it — wait below and re-evaluate.
}
} finally {
await releaseMutex();
}
if (owned) {
return { release };
}
await sleepFn(RESTART_LOCK_POLL_INTERVAL_MS);
}
}
/**
* Returns the systemctl --user enable command for a given unit.
* Used by the install auto-enable step to persist units across reboots.
@@ -1172,6 +1472,7 @@ export function isSendAccepted(capturedOutput: string): SendVerifyResult {
export function registerFleetCommand(program: Command, deps: FleetCommandDeps = {}): Command {
const runner = deps.runner ?? runCommand;
const sleepFn = deps.sleepFn ?? defaultSleep;
const paths = resolveFleetPaths(deps.mosaicHome);
const frameworkRoot = deps.frameworkRoot ?? resolveFrameworkRoot();
@@ -1285,9 +1586,22 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
.command(`${action} [agent]`)
.description(`${action} the fleet holder or one agent`)
.action(async (agent?: string) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const roster = await loadRosterForCommand(cmd);
if (agent) {
getRosterAgent(roster, agent);
// Single-agent restart is guarded too: it can race a full restart that
// is tearing the shared holder down.
if (action === 'restart') {
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action, agent));
} finally {
await guard.release();
}
return;
}
await runChecked(runner, buildFleetServiceCommand(action, agent));
return;
}
@@ -1298,6 +1612,21 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
);
return;
}
if (action === 'restart') {
// Serialize the holder+agents teardown/relaunch behind the restart lock
// so a re-entrant restart waits for clean shutdown before relaunching,
// instead of racing a half-torn-down holder into a tight loop.
const guard = await acquireRestartLock(activePaths.mosaicHome, sleepFn);
try {
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));
}
} finally {
await guard.release();
}
return;
}
await runChecked(runner, buildFleetServiceCommand(action));
for (const rosterAgent of roster.agents) {
await runChecked(runner, buildFleetServiceCommand(action, rosterAgent.name));

View File

@@ -4,5 +4,6 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
testTimeout: 30_000,
},
});

View File

@@ -0,0 +1,44 @@
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);
});
});

View File

@@ -0,0 +1,110 @@
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>;
}

View File

@@ -2,3 +2,5 @@
export interface AgentSessionHandle {
readonly id: string;
}
export * from './agent-runtime-provider.js';

View File

@@ -1,24 +1,109 @@
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;
/** Which guild to bind to (single-guild only for v0.1.0) */
/** Shared service credential injected by the approved secret mechanism. */
serviceToken: string;
/** Which guild to bind to (single-guild only for v0.1.0). */
guildId?: string;
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;
private config: DiscordPluginConfig;
/** Map Discord channel ID → Mosaic conversation ID */
/** 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(config: DiscordPluginConfig) {
this.config = config;
constructor(private readonly config: DiscordPluginConfig) {
this.client = new Client({
intents: [
GatewayIntentBits.Guilds,
@@ -30,8 +115,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'],
});
@@ -48,7 +133,6 @@ 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) {
@@ -56,12 +140,11 @@ 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) => {
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
});
}
@@ -71,8 +154,9 @@ export class DiscordPlugin {
this.pendingResponses.set(data.conversationId, '');
});
// Set up Discord message handler
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
this.client.on('messageCreate', (message: DiscordMessage) =>
this.handleDiscordMessage(message),
);
this.client.on('ready', () => {
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
@@ -96,7 +180,6 @@ export class DiscordPlugin {
const guild = this.client.guilds.cache.get(this.config.guildId);
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, '-')
@@ -108,58 +191,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 {
// Ignore bot messages
if (message.author.bot) return;
if (message.author.bot || !this.client.user) return;
if (!message.guildId || !this.isAllowedMessage(message)) return;
// Not ready yet
if (!this.client.user) return;
// Check guild binding
if (this.config.guildId && message.guildId !== this.config.guildId) return;
// Respond to DMs always, or mentions in channels
const isDM = !message.guildId;
const isMention = message.mentions.has(this.client.user);
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=${channelId}`,
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
);
return;
}
this.socket.emit('message', {
conversationId,
content,
});
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)
);
}
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];
@@ -177,12 +260,10 @@ export class DiscordPlugin {
return;
}
// Chunk responses for Discord's 2000-char limit
const chunks = this.chunkText(text, 1900);
for (const chunk of chunks) {
for (const chunk of this.chunkText(text, 1900)) {
try {
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
} catch (err) {
} catch (err: unknown) {
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
}
}
@@ -193,21 +274,16 @@ 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;
}
}

View File

@@ -16,6 +16,10 @@
# --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main)
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch
# end-to-end before cutting a release.
# --yes Accept all defaults; headless/non-interactive install
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
@@ -27,6 +31,7 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ──────────────────────────────────────────────────────────────────────────────
#
@@ -43,6 +48,7 @@ FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false
FLAG_UNINSTALL=false
FLAG_DEV=false
GIT_REF="${MOSAIC_REF:-main}"
# MOSAIC_ASSUME_YES env var acts the same as --yes
@@ -50,12 +56,18 @@ if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true
fi
# MOSAIC_DEV env var acts the same as --dev
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;;
--dev) FLAG_DEV=true; shift ;;
--yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -72,6 +84,17 @@ CLI_PKG="${SCOPE}/mosaic"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# In dev (build-from-source) mode the gateway is installed globally from a
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
# it with the registry @latest build (honored by gatewayConfigStage).
if [[ "$FLAG_DEV" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# Shared monorepo checkout (populated on demand by ensure_monorepo).
WORK_DIR=""
EXTRACTED_DIR=""
# ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line.
@@ -239,6 +262,99 @@ framework_version() {
fi
}
# Download + extract the monorepo archive at $GIT_REF exactly once per run.
# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the
# framework install (Part 1) and the dev build-from-source path (Part 2).
ensure_monorepo() {
if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
return 0
fi
require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
# shellcheck disable=SC2317
cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading source from ${GIT_REF}"
if command -v curl &>/dev/null; then
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
fail "curl or wget required to download source."
exit 1
fi
# Gitea archives extract to <repo-name>/ inside the work dir
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
fail "Could not locate extracted source in archive."
ls -la "$WORK_DIR" >&2
exit 1
fi
}
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
# (brain/config/db/…) are pulled from the registry at the versions pinned in
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
install_cli_from_source() {
local src="$EXTRACTED_DIR"
local out_dir="$WORK_DIR/dist-tarballs"
mkdir -p "$out_dir"
# pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
# Pin to the repo's packageManager version so the build matches CI. Surface
# corepack failures so the fresh-machine case gives an actionable error
# instead of a bare "command not found".
if ! command -v pnpm &>/dev/null; then
info "Activating pnpm via corepack…"
corepack enable 2>&1 | sed 's/^/ /' || warn "corepack enable failed — pnpm may need manual install."
corepack prepare pnpm@10.6.2 --activate 2>&1 | sed 's/^/ /' \
|| warn "corepack prepare failed — pnpm may need manual install."
fi
if ! command -v pnpm &>/dev/null; then
fail "pnpm not available after corepack activation."
echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
exit 1
fi
info "Installing workspace dependencies (pnpm install)…"
( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /'
info "Building CLI + gateway from source…"
( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /'
info "Packing local tarballs…"
( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
exit 1
fi
if [[ ! -f "$gw_tgz" ]]; then
fail "Gateway tarball was not produced by pnpm pack."
exit 1
fi
# Gateway first so it is present globally before the CLI's wizard runs (which
# skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
info "Installing gateway from source tarball (global)…"
npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
info "Installing CLI from source tarball (global)…"
npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "Installed from source: CLI $(installed_cli_version)"
}
# ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node
@@ -282,25 +398,8 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
warn "Framework not installed."
fi
else
# Download repo archive and extract framework
require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
cleanup_work() { rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading framework from ${GIT_REF}"
if command -v curl &>/dev/null; then
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
fail "curl or wget required to download framework."
exit 1
fi
# Gitea archives extract to <repo-name>/ inside the work dir
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
# Download repo archive and extract framework (shared with the dev build)
ensure_monorepo
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
if [[ ! -d "$FRAMEWORK_SRC" ]]; then
@@ -356,7 +455,11 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi
CURRENT="$(installed_cli_version)"
LATEST="$(latest_cli_version)"
if [[ "$FLAG_DEV" == "true" ]]; then
LATEST=""
else
LATEST="$(latest_cli_version)"
fi
if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}"
@@ -364,7 +467,9 @@ if [[ "$FLAG_CLI" == "true" ]]; then
dim " Installed: (none)"
fi
if [[ -n "$LATEST" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}"
else
dim " Latest: (registry unreachable)"
@@ -372,7 +477,9 @@ if [[ "$FLAG_CLI" == "true" ]]; then
echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ -z "$LATEST" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then
warn "Not installed."
@@ -383,6 +490,16 @@ if [[ "$FLAG_CLI" == "true" ]]; then
else
ok "Up to date (or ahead of registry)."
fi
elif [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}"
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
else
if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI."