Compare commits
22 Commits
fix/fleet-
...
726f7ab772
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
726f7ab772 | ||
|
|
fdd58a353b | ||
|
|
70851d9474 | ||
|
|
98b4f95284 | ||
|
|
0de3d51466 | ||
|
|
5f9067cf57 | ||
| e92186d768 | |||
| 119f64e69d | |||
| 46ca3ce742 | |||
| 227b73fcdf | |||
| a959b1d6b4 | |||
| 62f8177806 | |||
| 353e43c947 | |||
| ca9c2b5c23 | |||
| b580d37d51 | |||
| 59e49cfd15 | |||
| a99aded26d | |||
| 4df38f7e81 | |||
| 4e9e053800 | |||
| 851c67c27b | |||
| 86e106fcc9 | |||
| 67135d3822 |
@@ -5,3 +5,5 @@ pnpm-lock.yaml
|
|||||||
**/drizzle
|
**/drizzle
|
||||||
**/.next
|
**/.next
|
||||||
.claude/
|
.claude/
|
||||||
|
docs/tess/TASKS.md
|
||||||
|
docs/scratchpads/
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ variables:
|
|||||||
- &enable_pnpm 'corepack enable'
|
- &enable_pnpm 'corepack enable'
|
||||||
|
|
||||||
when:
|
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
|
# Turbo remote cache (turbo.mosaicstack.dev) is configured via Woodpecker
|
||||||
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
# repository-level environment variables (TURBO_API, TURBO_TEAM, TURBO_TOKEN).
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "^0.65.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"@mosaicstack/agent": "workspace:^",
|
||||||
"@mosaicstack/auth": "workspace:^",
|
"@mosaicstack/auth": "workspace:^",
|
||||||
"@mosaicstack/brain": "workspace:^",
|
"@mosaicstack/brain": "workspace:^",
|
||||||
"@mosaicstack/config": "workspace:^",
|
"@mosaicstack/config": "workspace:^",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class AdminHealthController {
|
|||||||
async check(): Promise<HealthStatusDto> {
|
async check(): Promise<HealthStatusDto> {
|
||||||
const [database, cache] = await Promise.all([this.checkDatabase(), this.checkCache()]);
|
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 providers = this.providerService.listProviders();
|
||||||
|
|
||||||
const allOk = database.status === 'ok' && cache.status === 'ok';
|
const allOk = database.status === 'ok' && cache.status === 'ok';
|
||||||
|
|||||||
142
apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts
Normal file
142
apps/gateway/src/agent/__tests__/agent-service-ownership.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapability,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeAuditEvent,
|
||||||
|
type RuntimeAuditSink,
|
||||||
|
type RuntimeApprovalVerifier,
|
||||||
|
} from '../runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
||||||
|
const CONTEXT = {
|
||||||
|
actorScope: OWNER_SCOPE,
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
||||||
|
readonly id = 'fleet';
|
||||||
|
readonly receivedScopes: RuntimeScope[] = [];
|
||||||
|
readonly sentMessages: RuntimeMessage[] = [];
|
||||||
|
terminateCalls = 0;
|
||||||
|
throwAfterSend = false;
|
||||||
|
|
||||||
|
constructor(private readonly supported: RuntimeCapability[]) {}
|
||||||
|
|
||||||
|
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return { supported: this.supported };
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamSession(
|
||||||
|
_sessionId: string,
|
||||||
|
_cursor: string | undefined,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
_sessionId: string,
|
||||||
|
message: RuntimeMessage,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
this.sentMessages.push(message);
|
||||||
|
if (this.throwAfterSend) {
|
||||||
|
throw new Error('provider acknowledgement failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return {
|
||||||
|
attachmentId: 'attachment-1',
|
||||||
|
sessionId,
|
||||||
|
mode,
|
||||||
|
expiresAt: '2026-07-12T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
this.terminateCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingAuditSink implements RuntimeAuditSink {
|
||||||
|
readonly events: RuntimeAuditEvent[] = [];
|
||||||
|
|
||||||
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
|
this.events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
async consume(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
|
||||||
|
|
||||||
|
async consume(
|
||||||
|
_approvalRef: string,
|
||||||
|
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.consumedAction = action;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
provider: RecordingRuntimeProvider,
|
||||||
|
audit: RuntimeAuditSink = new RecordingAuditSink(),
|
||||||
|
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
|
||||||
|
): RuntimeProviderService {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
registry.register(provider);
|
||||||
|
return new RuntimeProviderService(registry, audit, approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RuntimeProviderService security boundary', (): void => {
|
||||||
|
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerScope = provider.receivedScopes[0];
|
||||||
|
expect(providerScope).toEqual({
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(providerScope)).toBe(true);
|
||||||
|
expect(audit.events).toContainEqual({
|
||||||
|
providerId: 'fleet',
|
||||||
|
operation: 'session.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
resourceId: 'session-1',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(audit.events)).not.toContain('hello');
|
||||||
|
expect(JSON.stringify(audit.events)).not.toContain('key-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider([]);
|
||||||
|
const service = makeService(provider);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/capability denied/);
|
||||||
|
expect(provider.sentMessages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
|
const approval = new DenyingApprovalVerifier();
|
||||||
|
const service = makeService(provider, new RecordingAuditSink(), approval);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
||||||
|
).rejects.toThrow(/approval denied/);
|
||||||
|
expect(provider.terminateCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
|
const approval = new AcceptingApprovalVerifier();
|
||||||
|
const service = makeService(provider, new RecordingAuditSink(), approval);
|
||||||
|
|
||||||
|
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
|
||||||
|
|
||||||
|
expect(approval.consumedAction).toEqual({
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
});
|
||||||
|
expect(provider.terminateCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
const unavailableAudit: RuntimeAuditSink = {
|
||||||
|
async record(): Promise<void> {
|
||||||
|
throw new Error('audit unavailable');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = makeService(provider, unavailableAudit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/audit unavailable/);
|
||||||
|
expect(provider.sentMessages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
provider.throwAfterSend = true;
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/provider acknowledgement failed/);
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
|
||||||
|
'requested',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
let auditCalls = 0;
|
||||||
|
const audit: RuntimeAuditSink = {
|
||||||
|
async record(): Promise<void> {
|
||||||
|
auditCalls += 1;
|
||||||
|
if (auditCalls === 2) {
|
||||||
|
throw new Error('completion audit unavailable');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(auditCalls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
262
apps/gateway/src/agent/__tests__/session-ownership.test.ts
Normal file
262
apps/gateway/src/agent/__tests__/session-ownership.test.ts
Normal 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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -13,6 +14,14 @@ import { CoordModule } from '../coord/coord.module.js';
|
|||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import {
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
DenyRuntimeApprovalVerifier,
|
||||||
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js';
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
{
|
||||||
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
||||||
|
},
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
{
|
||||||
|
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
useExisting: RuntimeProviderAuditService,
|
||||||
|
},
|
||||||
|
DenyRuntimeApprovalVerifier,
|
||||||
|
{
|
||||||
|
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
useExisting: DenyRuntimeApprovalVerifier,
|
||||||
|
},
|
||||||
|
RuntimeProviderService,
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||||
@@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js';
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
createAgentSession,
|
createAgentSession,
|
||||||
DefaultResourceLoader,
|
DefaultResourceLoader,
|
||||||
@@ -28,6 +35,7 @@ import type { SessionInfoDto, SessionMetrics } from './session.dto.js';
|
|||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
import { PreferencesService } from '../preferences/preferences.service.js';
|
import { PreferencesService } from '../preferences/preferences.service.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.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. */
|
/** A single message from DB conversation history, used for context injection. */
|
||||||
export interface ConversationHistoryMessage {
|
export interface ConversationHistoryMessage {
|
||||||
@@ -68,6 +76,8 @@ export interface AgentSessionOptions {
|
|||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
/** ID of the user who owns this session. Used for preferences and system override lookups. */
|
||||||
userId?: string;
|
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.
|
* Prior conversation messages to inject as context when resuming a session.
|
||||||
* These messages are formatted and prepended to the system prompt so the
|
* These messages are formatted and prepended to the system prompt so the
|
||||||
@@ -94,6 +104,8 @@ export interface AgentSession {
|
|||||||
allowedTools: string[] | null;
|
allowedTools: string[] | null;
|
||||||
/** User ID that owns this session, used for preference lookups. */
|
/** User ID that owns this session, used for preference lookups. */
|
||||||
userId?: string;
|
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). */
|
/** Agent config ID applied to this session, if any (M5-001). */
|
||||||
agentConfigId?: string;
|
agentConfigId?: string;
|
||||||
/** Human-readable agent name applied to this session, if any (M5-001). */
|
/** 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);
|
.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);
|
const existing = this.sessions.get(sessionId);
|
||||||
if (existing) return existing;
|
if (existing) {
|
||||||
|
this.assertSessionScope(existing, scope);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
const inflight = this.creating.get(sessionId);
|
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(() => {
|
const promise = this.doCreateSession(sessionId, options).finally(() => {
|
||||||
this.creating.delete(sessionId);
|
this.creating.delete(sessionId);
|
||||||
@@ -342,6 +362,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
|
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
@@ -473,38 +494,70 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
return this.providerService.getDefaultModel() ?? null;
|
return this.providerService.getDefaultModel() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getSession(sessionId: string): AgentSession | undefined {
|
getSession(sessionId: string, scope: ActorTenantScope): AgentSession | undefined {
|
||||||
return this.sessions.get(sessionId);
|
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();
|
const now = Date.now();
|
||||||
return Array.from(this.sessions.values()).map((s) => ({
|
return Array.from(this.sessions.values())
|
||||||
id: s.id,
|
.filter((s) => this.sessionMatchesScope(s, scope))
|
||||||
provider: s.provider,
|
.map((s) => this.toSessionInfo(s, now));
|
||||||
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 },
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
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 {
|
return {
|
||||||
id: s.id,
|
userId: options.userId,
|
||||||
provider: s.provider,
|
tenantId: this.tenantIdFor(options.userId, options.tenantId) ?? options.userId,
|
||||||
modelId: s.modelId,
|
};
|
||||||
...(s.agentName ? { agentName: s.agentName } : {}),
|
}
|
||||||
createdAt: new Date(s.createdAt).toISOString(),
|
|
||||||
promptCount: s.promptCount,
|
private tenantIdFor(
|
||||||
channels: Array.from(s.channels),
|
userId: string | undefined,
|
||||||
durationMs: Date.now() - s.createdAt,
|
tenantId: string | undefined,
|
||||||
metrics: { ...s.metrics },
|
): 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
|
* 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.
|
* 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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
const prev = session.modelId;
|
const prev = session.modelId;
|
||||||
session.modelId = modelId;
|
session.modelId = modelId;
|
||||||
this.recordModelSwitch(sessionId);
|
this.recordModelSwitch(sessionId);
|
||||||
@@ -572,48 +626,51 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
agentConfigId: string,
|
agentConfigId: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
modelId?: string,
|
modelId?: string,
|
||||||
): void {
|
): void {
|
||||||
const session = this.sessions.get(sessionId);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
session.agentConfigId = agentConfigId;
|
session.agentConfigId = agentConfigId;
|
||||||
session.agentName = agentName;
|
session.agentName = agentName;
|
||||||
if (modelId) {
|
if (modelId) {
|
||||||
this.updateSessionModel(sessionId, modelId);
|
this.updateSessionModel(sessionId, modelId, scope);
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Session ${sessionId}: agent switched to "${agentName}" (${agentConfigId}) (M5-003)`,
|
`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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (!session) return;
|
||||||
session.channels.add(channel);
|
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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (session) {
|
if (!session) return;
|
||||||
session.channels.delete(channel);
|
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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
session.promptCount += 1;
|
session.promptCount += 1;
|
||||||
|
|
||||||
// Prepend session-scoped system override if present (renew TTL on each turn)
|
// Prepend session-scoped system override if present (renew TTL on each turn)
|
||||||
let effectiveMessage = message;
|
let effectiveMessage = message;
|
||||||
if (this.systemOverride) {
|
if (this.systemOverride) {
|
||||||
const override = await this.systemOverride.get(sessionId);
|
const override = await this.systemOverride.get(sessionId, scope);
|
||||||
if (override) {
|
if (override) {
|
||||||
effectiveMessage = `[System Override]\n${override}\n\n${message}`;
|
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}`);
|
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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error(`No agent session found: ${sessionId}`);
|
throw new Error(`No agent session found: ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
this.assertSessionScope(session, scope);
|
||||||
session.listeners.add(listener);
|
session.listeners.add(listener);
|
||||||
return () => session.listeners.delete(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);
|
const session = this.sessions.get(sessionId);
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
this.logger.log(`Destroying agent session ${sessionId}`);
|
this.logger.log(`Destroying agent session ${sessionId}`);
|
||||||
@@ -667,7 +736,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
|
|
||||||
async onModuleDestroy(): Promise<void> {
|
async onModuleDestroy(): Promise<void> {
|
||||||
this.logger.log('Shutting down all agent sessions');
|
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);
|
const results = await Promise.allSettled(stops);
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
|
|||||||
404
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
404
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapability,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
|
||||||
|
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
|
||||||
|
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
||||||
|
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
||||||
|
|
||||||
|
export type RuntimeProviderOperation =
|
||||||
|
| RuntimeCapability
|
||||||
|
| 'runtime.capabilities'
|
||||||
|
| 'runtime.health';
|
||||||
|
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
|
|
||||||
|
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
||||||
|
export interface RuntimeProviderRequestContext {
|
||||||
|
actorScope: ActorTenantScope;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
|
||||||
|
export interface RuntimeAuditEvent {
|
||||||
|
providerId: string;
|
||||||
|
operation: RuntimeProviderOperation;
|
||||||
|
outcome: RuntimeProviderAuditOutcome;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
resourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeAuditSink {
|
||||||
|
record(event: RuntimeAuditEvent): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact action shape that a durable approval implementation must consume once. */
|
||||||
|
export interface RuntimeTerminationAction {
|
||||||
|
providerId: string;
|
||||||
|
sessionId: string;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeApprovalVerifier {
|
||||||
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuntimeApprovalDeniedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Runtime termination approval denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default denies all runtime termination until a durable, exact-action
|
||||||
|
* approval implementation is configured. This is safer than a permissive stub.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary metadata-only audit sink. M1 observability can replace this token
|
||||||
|
* with a durable audit writer without changing provider call sites.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
||||||
|
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
||||||
|
|
||||||
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
|
this.logger.log(JSON.stringify(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RuntimeProviderService {
|
||||||
|
private readonly logger = new Logger(RuntimeProviderService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
|
||||||
|
private readonly registry: AgentRuntimeProviderRegistry,
|
||||||
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
|
private readonly audit: RuntimeAuditSink,
|
||||||
|
@Inject(RUNTIME_APPROVAL_VERIFIER)
|
||||||
|
private readonly approvals: RuntimeApprovalVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async capabilities(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeCapabilitySet> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.capabilities',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
|
||||||
|
provider.capabilities(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.health',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
|
||||||
|
provider.health(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeSession[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.list',
|
||||||
|
'session.list',
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
|
||||||
|
provider.listSessions(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeSessionTree[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.tree',
|
||||||
|
'session.tree',
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
|
||||||
|
provider.getSessionTree(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamSession(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
cursor: string | undefined,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
return this.stream(
|
||||||
|
providerId,
|
||||||
|
'session.stream',
|
||||||
|
'session.stream',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
|
||||||
|
provider.streamSession(sessionId, cursor, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
message: RuntimeMessage,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.send',
|
||||||
|
'session.send',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||||
|
provider.sendMessage(sessionId, message, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.attach',
|
||||||
|
'session.attach',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
|
||||||
|
provider.attach(sessionId, mode, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
providerId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.attach',
|
||||||
|
'session.attach',
|
||||||
|
attachmentId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||||
|
provider.detach(attachmentId, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
approvalRef: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.terminate',
|
||||||
|
'session.terminate',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
|
||||||
|
const approved = await this.approvals.consume(approvalRef, {
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
if (!approved) {
|
||||||
|
throw new RuntimeApprovalDeniedError();
|
||||||
|
}
|
||||||
|
await provider.terminate(sessionId, approvalRef, scope);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute<T>(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
requiredCapability: RuntimeCapability | undefined,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const scope = this.deriveScope(context);
|
||||||
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
|
let invocationStarted = false;
|
||||||
|
try {
|
||||||
|
const provider = this.provider(providerId);
|
||||||
|
if (requiredCapability) {
|
||||||
|
await this.assertCapability(provider, requiredCapability, scope);
|
||||||
|
}
|
||||||
|
invocationStarted = true;
|
||||||
|
const result = await invoke(provider, scope);
|
||||||
|
await this.recordCompletion(providerId, operation, scope, resourceId);
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId);
|
||||||
|
} else {
|
||||||
|
await this.record(providerId, operation, 'denied', scope, resourceId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *stream(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
requiredCapability: RuntimeCapability,
|
||||||
|
resourceId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
invoke: (
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
) => AsyncIterable<RuntimeStreamEvent>,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
const scope = this.deriveScope(context);
|
||||||
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
|
let invocationStarted = false;
|
||||||
|
try {
|
||||||
|
const provider = this.provider(providerId);
|
||||||
|
await this.assertCapability(provider, requiredCapability, scope);
|
||||||
|
invocationStarted = true;
|
||||||
|
for await (const event of invoke(provider, scope)) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
await this.recordCompletion(providerId, operation, scope, resourceId);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (invocationStarted) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId);
|
||||||
|
} else {
|
||||||
|
await this.record(providerId, operation, 'denied', scope, resourceId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private provider(providerId: string): AgentRuntimeProvider {
|
||||||
|
try {
|
||||||
|
return this.registry.require(providerId);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
|
||||||
|
throw new NotFoundException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCapability(
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
requiredCapability: RuntimeCapability,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void> {
|
||||||
|
const capabilities = await provider.capabilities(scope);
|
||||||
|
if (!capabilities.supported.includes(requiredCapability)) {
|
||||||
|
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
|
||||||
|
const actorId = context.actorScope.userId.trim();
|
||||||
|
const tenantId = context.actorScope.tenantId.trim();
|
||||||
|
const channelId = context.channelId.trim();
|
||||||
|
const correlationId = context.correlationId.trim();
|
||||||
|
if (!actorId || !tenantId || !channelId || !correlationId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Authenticated runtime actor scope and correlation are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ actorId, tenantId, channelId, correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordFailure(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.record(providerId, operation, 'failed', scope, resourceId);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordCompletion(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.record(providerId, operation, 'succeeded', scope, resourceId);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async record(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
outcome: RuntimeProviderAuditOutcome,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.audit.record({
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
outcome,
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
...(resourceId ? { resourceId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { AuthGuard } from '../auth/auth.guard.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 { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
|
|
||||||
@Controller('api/sessions')
|
@Controller('api/sessions')
|
||||||
@@ -18,23 +20,24 @@ export class SessionsController {
|
|||||||
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
constructor(@Inject(AgentService) private readonly agentService: AgentService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list() {
|
list(@CurrentUser() user: AuthenticatedUserLike) {
|
||||||
const sessions = this.agentService.listSessions();
|
const sessions = this.agentService.listSessions(scopeFromUser(user));
|
||||||
return { sessions, total: sessions.length };
|
return { sessions, total: sessions.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||||
const info = this.agentService.getSessionInfo(id);
|
const info = this.agentService.getSessionInfo(id, scopeFromUser(user));
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async destroy(@Param('id') id: string) {
|
async destroy(@Param('id') id: string, @CurrentUser() user: AuthenticatedUserLike) {
|
||||||
const info = this.agentService.getSessionInfo(id);
|
const scope = scopeFromUser(user);
|
||||||
|
const info = this.agentService.getSessionInfo(id, scope);
|
||||||
if (!info) throw new NotFoundException('Session not found');
|
if (!info) throw new NotFoundException('Session not found');
|
||||||
await this.agentService.destroySession(id);
|
await this.agentService.destroySession(id, scope);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
apps/gateway/src/auth/session-scope.ts
Normal file
24
apps/gateway/src/auth/session-scope.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ describe('Chat controller source hardening', () => {
|
|||||||
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
const source = readFileSync(resolve('src/chat/chat.controller.ts'), 'utf8');
|
||||||
|
|
||||||
expect(source).toContain('@UseGuards(AuthGuard)');
|
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);');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Body,
|
Body,
|
||||||
Logger,
|
Logger,
|
||||||
|
ForbiddenException,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
NotFoundException,
|
||||||
Inject,
|
Inject,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -13,6 +15,7 @@ import { Throttle } from '@nestjs/throttler';
|
|||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.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 { v4 as uuid } from 'uuid';
|
||||||
import { ChatRequestDto } from './chat.dto.js';
|
import { ChatRequestDto } from './chat.dto.js';
|
||||||
|
|
||||||
@@ -32,16 +35,23 @@ export class ChatController {
|
|||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
async chat(
|
async chat(
|
||||||
@Body() body: ChatRequestDto,
|
@Body() body: ChatRequestDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: AuthenticatedUserLike,
|
||||||
): Promise<ChatResponse> {
|
): Promise<ChatResponse> {
|
||||||
const conversationId = body.conversationId ?? uuid();
|
const conversationId = body.conversationId ?? uuid();
|
||||||
|
const scope = scopeFromUser(user);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
agentSession = await this.agentService.createSession(conversationId);
|
agentSession = await this.agentService.createSession(conversationId, {
|
||||||
|
userId: scope.userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ForbiddenException) {
|
||||||
|
throw new NotFoundException('Session not found');
|
||||||
|
}
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Session creation failed for conversation=${conversationId}`,
|
`Session creation failed for conversation=${conversationId}`,
|
||||||
err instanceof Error ? err.stack : String(err),
|
err instanceof Error ? err.stack : String(err),
|
||||||
@@ -60,20 +70,27 @@ export class ChatController {
|
|||||||
reject(new Error('Agent response timed out'));
|
reject(new Error('Agent response timed out'));
|
||||||
}, 120_000);
|
}, 120_000);
|
||||||
|
|
||||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
const cleanup = this.agentService.onEvent(
|
||||||
if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
|
conversationId,
|
||||||
responseText += event.assistantMessageEvent.delta;
|
(event: AgentSessionEvent) => {
|
||||||
}
|
if (
|
||||||
if (event.type === 'agent_end') {
|
event.type === 'message_update' &&
|
||||||
clearTimeout(timer);
|
event.assistantMessageEvent.type === 'text_delta'
|
||||||
cleanup();
|
) {
|
||||||
resolve();
|
responseText += event.assistantMessageEvent.delta;
|
||||||
}
|
}
|
||||||
});
|
if (event.type === 'agent_end') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
cleanup();
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scope,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, body.content);
|
await this.agentService.prompt(conversationId, body.content, scope);
|
||||||
await done;
|
await done;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpException) throw err;
|
if (err instanceof HttpException) throw err;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
import type { IncomingHttpHeaders } from 'node:http';
|
import type { IncomingHttpHeaders } from 'node:http';
|
||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
|
|
||||||
@@ -12,6 +13,19 @@ export interface SessionAuth {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function validateDiscordServiceToken(
|
||||||
|
candidate: unknown,
|
||||||
|
expected: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (typeof candidate !== 'string' || !expected) return false;
|
||||||
|
const candidateBuffer = Buffer.from(candidate);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
return (
|
||||||
|
candidateBuffer.length === expectedBuffer.length &&
|
||||||
|
timingSafeEqual(candidateBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function validateSocketSession(
|
export async function validateSocketSession(
|
||||||
headers: IncomingHttpHeaders,
|
headers: IncomingHttpHeaders,
|
||||||
auth: SessionAuth,
|
auth: SessionAuth,
|
||||||
|
|||||||
74
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
74
apps/gateway/src/chat/chat.gateway-command-approval.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
|
||||||
|
const payload: SlashCommandPayload = {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildGateway(commandExecutor: {
|
||||||
|
execute: ReturnType<typeof vi.fn>;
|
||||||
|
createApproval: ReturnType<typeof vi.fn>;
|
||||||
|
}): ChatGateway {
|
||||||
|
return new ChatGateway(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
commandExecutor as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatGateway command approval ingress', () => {
|
||||||
|
it('passes the client approval ID through to command execution while deriving the actor server-side', async (): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn().mockResolvedValue({ ...payload, success: true }),
|
||||||
|
createApproval: vi.fn(),
|
||||||
|
};
|
||||||
|
const gateway = buildGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandExecute(client as never, payload);
|
||||||
|
|
||||||
|
expect(commandExecutor.execute).toHaveBeenCalledWith(payload, {
|
||||||
|
userId: 'admin-1',
|
||||||
|
tenantId: 'admin-1',
|
||||||
|
});
|
||||||
|
expect(client.emit).toHaveBeenCalledWith(
|
||||||
|
'command:result',
|
||||||
|
expect.objectContaining({ success: true }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('issues a durable approval only for the authenticated actor', async (): Promise<void> => {
|
||||||
|
const commandExecutor = {
|
||||||
|
execute: vi.fn(),
|
||||||
|
createApproval: vi.fn().mockResolvedValue({
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const gateway = buildGateway(commandExecutor);
|
||||||
|
const client = { data: { user: { id: 'admin-1' } }, emit: vi.fn() };
|
||||||
|
|
||||||
|
await gateway.handleCommandApproval(client as never, {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandExecutor.createApproval).toHaveBeenCalledWith(
|
||||||
|
{ command: 'gc', conversationId: 'conversation-1' },
|
||||||
|
{ userId: 'admin-1', tenantId: 'admin-1' },
|
||||||
|
);
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('command:approval', {
|
||||||
|
command: 'gc',
|
||||||
|
conversationId: 'conversation-1',
|
||||||
|
success: true,
|
||||||
|
approvalId: 'approval-1',
|
||||||
|
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
170
apps/gateway/src/chat/chat.gateway-redaction.spec.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ChatGateway } from './chat.gateway.js';
|
||||||
|
|
||||||
|
const CONVERSATION_ID = 'conversation-1';
|
||||||
|
const CANARY = 'sk_canary12345678';
|
||||||
|
|
||||||
|
type GatewayInternals = {
|
||||||
|
clientSessions: Map<string, unknown>;
|
||||||
|
relayEvent(client: unknown, conversationId: string, event: unknown): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildGateway() {
|
||||||
|
const brain = {
|
||||||
|
conversations: {
|
||||||
|
addMessage: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const agentService = {
|
||||||
|
getSession: vi.fn().mockReturnValue(undefined),
|
||||||
|
};
|
||||||
|
const gateway = new ChatGateway(
|
||||||
|
agentService as never,
|
||||||
|
{} as never,
|
||||||
|
brain as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { gateway: gateway as unknown as GatewayInternals, brain };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ChatGateway redaction boundary', (): void => {
|
||||||
|
it('redacts a secret split across assistant deltas before egress and persistence', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
const session = {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
assistantText: '',
|
||||||
|
toolCalls: [],
|
||||||
|
pendingToolCalls: new Map(),
|
||||||
|
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
||||||
|
};
|
||||||
|
gateway.clientSessions.set(client.id, session);
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'sk_canary' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('sk_canary');
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '12345678 ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_SECRET] ',
|
||||||
|
});
|
||||||
|
expect(session.assistantText).toBe(`${CANARY} `);
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain(CANARY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains a split secret label until its value can be redacted', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'token ' },
|
||||||
|
});
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '=canaryvalue123 ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_SECRET] ',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canaryvalue123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('holds a streamed private key until it can be redacted', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '-----BEGIN PRIVATE KEY-----\ncanary' },
|
||||||
|
});
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: '\n-----END PRIVATE KEY-----' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_SECRET]',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(client.emit.mock.calls)).not.toContain('canary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an oversized unterminated stream fragment rather than retaining it', (): void => {
|
||||||
|
const { gateway } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, {
|
||||||
|
type: 'message_update',
|
||||||
|
assistantMessageEvent: { type: 'text_delta', delta: 'x'.repeat(8_193) },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(client.emit).toHaveBeenCalledWith('agent:text', {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
text: '[REDACTED_STREAM_OVERFLOW]',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists only redacted assistant content with classifications', (): void => {
|
||||||
|
const { gateway, brain } = buildGateway();
|
||||||
|
const client = {
|
||||||
|
connected: true,
|
||||||
|
id: 'client-1',
|
||||||
|
data: { user: { id: 'user-1' } },
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
gateway.clientSessions.set(client.id, {
|
||||||
|
conversationId: CONVERSATION_ID,
|
||||||
|
cleanup: vi.fn(),
|
||||||
|
assistantText: CANARY,
|
||||||
|
toolCalls: [],
|
||||||
|
pendingToolCalls: new Map(),
|
||||||
|
scope: { userId: 'user-1', tenantId: 'tenant-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
gateway.relayEvent(client, CONVERSATION_ID, { type: 'agent_end' });
|
||||||
|
|
||||||
|
expect(brain.conversations.addMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
content: '[REDACTED_SECRET]',
|
||||||
|
metadata: expect.objectContaining({ classifications: ['secret'] }),
|
||||||
|
}),
|
||||||
|
'user-1',
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(brain.conversations.addMessage.mock.calls)).not.toContain(CANARY);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,10 +11,17 @@ import {
|
|||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
import { Server, Socket } from 'socket.io';
|
import { Server, Socket } from 'socket.io';
|
||||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||||
|
import {
|
||||||
|
verifyDiscordIngressEnvelope,
|
||||||
|
type DiscordIngressEnvelope,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
|
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||||
import type {
|
import type {
|
||||||
SetThinkingPayload,
|
SetThinkingPayload,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
RoutingDecisionInfo,
|
RoutingDecisionInfo,
|
||||||
@@ -22,13 +29,19 @@ import type {
|
|||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.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 { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||||
import { validateSocketSession } from './chat.gateway-auth.js';
|
import { validateDiscordServiceToken, validateSocketSession } from './chat.gateway-auth.js';
|
||||||
|
import { DiscordReplayProtector } from '../plugin/discord-replay-protector.js';
|
||||||
|
|
||||||
/** Per-client state tracking streaming accumulation for persistence. */
|
/** Per-client state tracking streaming accumulation for persistence. */
|
||||||
interface ClientSession {
|
interface ClientSession {
|
||||||
@@ -40,6 +53,8 @@ interface ClientSession {
|
|||||||
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
toolCalls: Array<{ toolCallId: string; toolName: string; args: unknown; isError: boolean }>;
|
||||||
/** Tool calls in-flight (started but not ended yet). */
|
/** Tool calls in-flight (started but not ended yet). */
|
||||||
pendingToolCalls: Map<string, { toolName: string; args: unknown }>;
|
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) */
|
/** Last routing decision made for this session (M4-008) */
|
||||||
lastRoutingDecision?: RoutingDecisionInfo;
|
lastRoutingDecision?: RoutingDecisionInfo;
|
||||||
}
|
}
|
||||||
@@ -49,6 +64,38 @@ interface ClientSession {
|
|||||||
* Keyed by conversationId, value is the model name to use.
|
* Keyed by conversationId, value is the model name to use.
|
||||||
*/
|
*/
|
||||||
const modelOverrides = new Map<string, string>();
|
const modelOverrides = new Map<string, string>();
|
||||||
|
const MAX_REDACTION_BUFFER_LENGTH = 8_192;
|
||||||
|
|
||||||
|
function isDiscordIngressEnvelope(value: unknown): value is DiscordIngressEnvelope {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const envelope = value as { payload?: unknown; signature?: unknown };
|
||||||
|
if (
|
||||||
|
typeof envelope.signature !== 'string' ||
|
||||||
|
typeof envelope.payload !== 'object' ||
|
||||||
|
envelope.payload === null
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const payload = envelope.payload as Record<string, unknown>;
|
||||||
|
return [
|
||||||
|
payload['correlationId'],
|
||||||
|
payload['messageId'],
|
||||||
|
payload['guildId'],
|
||||||
|
payload['channelId'],
|
||||||
|
payload['userId'],
|
||||||
|
payload['conversationId'],
|
||||||
|
payload['content'],
|
||||||
|
].every((field: unknown): boolean => typeof field === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChatSocketMessage(value: unknown): value is ChatSocketMessageDto {
|
||||||
|
if (typeof value !== 'object' || value === null) return false;
|
||||||
|
const payload = value as { content?: unknown; conversationId?: unknown };
|
||||||
|
return (
|
||||||
|
typeof payload.content === 'string' &&
|
||||||
|
(payload.conversationId === undefined || typeof payload.conversationId === 'string')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@WebSocketGateway({
|
@WebSocketGateway({
|
||||||
cors: {
|
cors: {
|
||||||
@@ -62,6 +109,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
private readonly logger = new Logger(ChatGateway.name);
|
private readonly logger = new Logger(ChatGateway.name);
|
||||||
private readonly clientSessions = new Map<string, ClientSession>();
|
private readonly clientSessions = new Map<string, ClientSession>();
|
||||||
|
/** Raw stream fragments are kept in memory only until they are safe to redact and emit. */
|
||||||
|
private readonly textEgressBuffers = new Map<string, string>();
|
||||||
|
private readonly thinkingEgressBuffers = new Map<string, string>();
|
||||||
|
private readonly overflowedEgress = new Set<string>();
|
||||||
|
private readonly discordReplayProtector = new DiscordReplayProtector();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(AgentService) private readonly agentService: AgentService,
|
@Inject(AgentService) private readonly agentService: AgentService,
|
||||||
@@ -77,6 +129,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleConnection(client: Socket): Promise<void> {
|
async handleConnection(client: Socket): Promise<void> {
|
||||||
|
const serviceToken = client.handshake.auth['discordServiceToken'];
|
||||||
|
if (validateDiscordServiceToken(serviceToken, process.env['DISCORD_SERVICE_TOKEN'])) {
|
||||||
|
client.data.discordService = true;
|
||||||
|
this.logger.log(`Authenticated Discord service connected: ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
const session = await validateSocketSession(client.handshake.headers, this.auth);
|
||||||
if (!session) {
|
if (!session) {
|
||||||
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
this.logger.warn(`Rejected unauthenticated WebSocket client: ${client.id}`);
|
||||||
@@ -87,8 +146,6 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
client.data.user = session.user;
|
client.data.user = session.user;
|
||||||
client.data.session = session.session;
|
client.data.session = session.session;
|
||||||
this.logger.log(`Client connected: ${client.id}`);
|
this.logger.log(`Client connected: ${client.id}`);
|
||||||
|
|
||||||
// Broadcast command manifest to the newly connected client
|
|
||||||
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
client.emit('commands:manifest', { manifest: this.commandRegistry.getManifest() });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,25 +154,84 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const session = this.clientSessions.get(client.id);
|
const session = this.clientSessions.get(client.id);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.cleanup();
|
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);
|
this.clientSessions.delete(client.id);
|
||||||
}
|
}
|
||||||
|
this.textEgressBuffers.delete(client.id);
|
||||||
|
this.thinkingEgressBuffers.delete(client.id);
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
||||||
|
}
|
||||||
|
|
||||||
|
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')
|
@SubscribeMessage('message')
|
||||||
async handleMessage(
|
async handleMessage(
|
||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: ChatSocketMessageDto,
|
@MessageBody() rawData: unknown,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
let discordIngress: DiscordIngressPayload | null = null;
|
||||||
|
let data: ChatSocketMessageDto;
|
||||||
|
if (client.data.discordService) {
|
||||||
|
if (!isDiscordIngressEnvelope(rawData)) {
|
||||||
|
this.logger.warn(`Rejected malformed Discord ingress from ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
discordIngress = this.resolveDiscordIngress(client, rawData);
|
||||||
|
if (!discordIngress) return;
|
||||||
|
data = { conversationId: discordIngress.conversationId, content: discordIngress.content };
|
||||||
|
} else {
|
||||||
|
if (!isChatSocketMessage(rawData)) {
|
||||||
|
this.logger.warn(`Rejected malformed chat message from ${client.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data = rawData;
|
||||||
|
}
|
||||||
const conversationId = data.conversationId ?? uuid();
|
const conversationId = data.conversationId ?? uuid();
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id;
|
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
if (discordIngress && !discordServiceUserId) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected Discord ingress without configured service owner from ${client.id}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const 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
|
// Ensure agent session exists for this conversation
|
||||||
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
let sessionRoutingDecision: RoutingDecisionInfo | undefined;
|
||||||
try {
|
try {
|
||||||
let agentSession = this.agentService.getSession(conversationId);
|
let agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (!agentSession) {
|
if (!agentSession) {
|
||||||
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
// When resuming an existing conversation, load prior messages to inject as context (M1-004)
|
||||||
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
const conversationHistory = await this.loadConversationHistory(conversationId, userId);
|
||||||
@@ -135,7 +251,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
let resolvedProvider = data.provider;
|
let resolvedProvider = data.provider;
|
||||||
let resolvedModelId = data.modelId;
|
let resolvedModelId = data.modelId;
|
||||||
|
|
||||||
const modelOverride = modelOverrides.get(conversationId);
|
const modelOverride = modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||||
if (modelOverride) {
|
if (modelOverride) {
|
||||||
// /model override bypasses routing engine (M4-007)
|
// /model override bypasses routing engine (M4-007)
|
||||||
resolvedModelId = modelOverride;
|
resolvedModelId = modelOverride;
|
||||||
@@ -172,6 +288,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
modelId: resolvedModelId,
|
modelId: resolvedModelId,
|
||||||
agentConfigId: data.agentId,
|
agentConfigId: data.agentId,
|
||||||
userId,
|
userId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,9 +327,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: data.content,
|
content: redactSensitiveContent(data.content).content,
|
||||||
metadata: {
|
metadata: {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
...(correlationId
|
||||||
|
? {
|
||||||
|
correlationId,
|
||||||
|
discordMessageId: discordIngress?.messageId,
|
||||||
|
discordUserId: discordIngress?.userId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
classifications: redactSensitiveContent(data.content).classifications,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
@@ -232,9 +357,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to agent events and relay to client
|
// Subscribe to agent events and relay to client
|
||||||
const cleanup = this.agentService.onEvent(conversationId, (event: AgentSessionEvent) => {
|
const cleanup = this.agentService.onEvent(
|
||||||
this.relayEvent(client, conversationId, event);
|
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
|
// Preserve routing decision from the existing client session if we didn't get a new one
|
||||||
const prevClientSession = this.clientSessions.get(client.id);
|
const prevClientSession = this.clientSessions.get(client.id);
|
||||||
@@ -246,16 +375,17 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
assistantText: '',
|
assistantText: '',
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
pendingToolCalls: new Map(),
|
pendingToolCalls: new Map(),
|
||||||
|
scope,
|
||||||
lastRoutingDecision: routingDecisionToStore,
|
lastRoutingDecision: routingDecisionToStore,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track channel connection
|
// 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)
|
// 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)
|
// 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) {
|
if (agentSession) {
|
||||||
const piSession = agentSession.piSession;
|
const piSession = agentSession.piSession;
|
||||||
client.emit('session:info', {
|
client.emit('session:info', {
|
||||||
@@ -271,11 +401,21 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Send acknowledgment
|
// Send acknowledgment
|
||||||
client.emit('message:ack', { conversationId, messageId: uuid() });
|
client.emit('message:ack', {
|
||||||
|
conversationId,
|
||||||
|
messageId: uuid(),
|
||||||
|
...(correlationId
|
||||||
|
? {
|
||||||
|
correlationId,
|
||||||
|
discordMessageId: discordIngress?.messageId,
|
||||||
|
discordUserId: discordIngress?.userId,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
// Dispatch to agent
|
// Dispatch to agent
|
||||||
try {
|
try {
|
||||||
await this.agentService.prompt(conversationId, data.content);
|
await this.agentService.prompt(conversationId, data.content, scope);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
`Agent prompt failed for client=${client.id}, conversation=${conversationId}`,
|
||||||
@@ -293,7 +433,16 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() data: SetThinkingPayload,
|
@MessageBody() data: SetThinkingPayload,
|
||||||
): void {
|
): 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) {
|
if (!session) {
|
||||||
client.emit('error', {
|
client.emit('error', {
|
||||||
conversationId: data.conversationId,
|
conversationId: data.conversationId,
|
||||||
@@ -334,7 +483,13 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
const conversationId = data.conversationId;
|
const conversationId = data.conversationId;
|
||||||
this.logger.log(`Abort requested by ${client.id} for conversation ${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) {
|
if (!session) {
|
||||||
client.emit('error', {
|
client.emit('error', {
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -363,11 +518,45 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@ConnectedSocket() client: Socket,
|
@ConnectedSocket() client: Socket,
|
||||||
@MessageBody() payload: SlashCommandPayload,
|
@MessageBody() payload: SlashCommandPayload,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const userId = (client.data.user as { id: string } | undefined)?.id ?? 'unknown';
|
const scope = this.getClientScope(client);
|
||||||
const result = await this.commandExecutor.execute(payload, userId);
|
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);
|
client.emit('command:result', result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeMessage('command:approve')
|
||||||
|
async handleCommandApproval(
|
||||||
|
@ConnectedSocket() client: Socket,
|
||||||
|
@MessageBody() payload: SlashCommandPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const scope = this.getClientScope(client);
|
||||||
|
const approval = scope ? await this.commandExecutor.createApproval(payload, scope) : null;
|
||||||
|
const result: SlashCommandApprovalResultPayload = approval
|
||||||
|
? {
|
||||||
|
command: payload.command,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
success: true,
|
||||||
|
approvalId: approval.approvalId,
|
||||||
|
expiresAt: approval.expiresAt,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
command: payload.command,
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
success: false,
|
||||||
|
message: 'Not authorized to approve this command.',
|
||||||
|
};
|
||||||
|
client.emit('command:approval', result);
|
||||||
|
}
|
||||||
|
|
||||||
broadcastReload(payload: SystemReloadPayload): void {
|
broadcastReload(payload: SystemReloadPayload): void {
|
||||||
this.server.emit('system:reload', payload);
|
this.server.emit('system:reload', payload);
|
||||||
this.logger.log('Broadcasted system:reload to all connected clients');
|
this.logger.log('Broadcasted system:reload to all connected clients');
|
||||||
@@ -380,18 +569,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-005: Emits session:info to clients subscribed to this conversation when a model is set.
|
||||||
* M5-007: Records a model switch in session metrics.
|
* 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) {
|
if (modelName) {
|
||||||
modelOverrides.set(conversationId, modelName);
|
modelOverrides.set(key, modelName);
|
||||||
this.logger.log(`Model override set: conversation=${conversationId} model="${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
|
// 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
|
// M5-005: Broadcast session:info to all clients subscribed to this conversation
|
||||||
this.broadcastSessionInfo(conversationId);
|
this.broadcastSessionInfo(conversationId, scope);
|
||||||
} else {
|
} else {
|
||||||
modelOverrides.delete(conversationId);
|
modelOverrides.delete(key);
|
||||||
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
this.logger.log(`Model override cleared: conversation=${conversationId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -399,8 +593,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
/**
|
/**
|
||||||
* Return the active model override for a conversation, or undefined if none.
|
* Return the active model override for a conversation, or undefined if none.
|
||||||
*/
|
*/
|
||||||
getModelOverride(conversationId: string): string | undefined {
|
getModelOverride(conversationId: string, scope: ActorTenantScope): string | undefined {
|
||||||
return modelOverrides.get(conversationId);
|
return modelOverrides.get(this.modelOverrideKey(conversationId, scope));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -409,9 +603,10 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
*/
|
*/
|
||||||
broadcastSessionInfo(
|
broadcastSessionInfo(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
extra?: { agentName?: string; routingDecision?: RoutingDecisionInfo },
|
||||||
): void {
|
): void {
|
||||||
const agentSession = this.agentService.getSession(conversationId);
|
const agentSession = this.agentService.getSession(conversationId, scope);
|
||||||
if (!agentSession) return;
|
if (!agentSession) return;
|
||||||
|
|
||||||
const piSession = agentSession.piSession;
|
const piSession = agentSession.piSession;
|
||||||
@@ -428,7 +623,7 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
|
|
||||||
// Emit to all clients currently subscribed to this conversation
|
// Emit to all clients currently subscribed to this conversation
|
||||||
for (const [clientId, session] of this.clientSessions) {
|
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);
|
const socket = this.server.sockets.sockets.get(clientId);
|
||||||
if (socket?.connected) {
|
if (socket?.connected) {
|
||||||
socket.emit('session:info', payload);
|
socket.emit('session:info', payload);
|
||||||
@@ -442,6 +637,39 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||||
* would fail on the PK constraint and be caught here.
|
* would fail on the PK constraint and be caught here.
|
||||||
*/
|
*/
|
||||||
|
private resolveDiscordIngress(
|
||||||
|
client: Socket,
|
||||||
|
envelope: DiscordIngressEnvelope,
|
||||||
|
): DiscordIngressPayload | null {
|
||||||
|
const payload = verifyDiscordIngressEnvelope(
|
||||||
|
envelope,
|
||||||
|
process.env['DISCORD_SERVICE_TOKEN'] ?? '',
|
||||||
|
{
|
||||||
|
guildIds: this.readDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
|
channelIds: this.readDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
|
userIds: this.readDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!payload) {
|
||||||
|
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readDiscordAllowlist(name: string): string[] {
|
||||||
|
return (process.env[name] ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((id: string): string => id.trim())
|
||||||
|
.filter((id: string): boolean => id.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
private async ensureConversation(conversationId: string, userId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const existing = await this.brain.conversations.findById(conversationId, userId);
|
const existing = await this.brain.conversations.findById(conversationId, userId);
|
||||||
@@ -527,6 +755,127 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private appendAndFlushRedactedEgress(
|
||||||
|
client: Socket,
|
||||||
|
conversationId: string,
|
||||||
|
eventName: 'agent:text' | 'agent:thinking',
|
||||||
|
buffers: Map<string, string>,
|
||||||
|
delta: string,
|
||||||
|
): void {
|
||||||
|
const key = this.egressKey(client, eventName);
|
||||||
|
if (this.overflowedEgress.has(key)) return;
|
||||||
|
|
||||||
|
const buffered = `${buffers.get(client.id) ?? ''}${delta}`;
|
||||||
|
if (buffered.length > MAX_REDACTION_BUFFER_LENGTH) {
|
||||||
|
buffers.delete(client.id);
|
||||||
|
this.overflowedEgress.add(key);
|
||||||
|
client.emit(eventName, { conversationId, text: '[REDACTED_STREAM_OVERFLOW]' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffers.set(client.id, buffered);
|
||||||
|
this.flushRedactedEgress(client, conversationId, eventName, buffers, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds any suffix that could become a secret, email, or phone number after a
|
||||||
|
* later stream chunk. This avoids relying on downstream redaction after data
|
||||||
|
* has already reached the socket.
|
||||||
|
*/
|
||||||
|
private flushRedactedEgress(
|
||||||
|
client: Socket,
|
||||||
|
conversationId: string,
|
||||||
|
eventName: 'agent:text' | 'agent:thinking',
|
||||||
|
buffers: Map<string, string>,
|
||||||
|
final: boolean,
|
||||||
|
): void {
|
||||||
|
const key = this.egressKey(client, eventName);
|
||||||
|
if (this.overflowedEgress.has(key)) {
|
||||||
|
if (final) this.overflowedEgress.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffered = buffers.get(client.id) ?? '';
|
||||||
|
const releaseLength = final ? buffered.length : this.safeRedactionPrefixLength(buffered);
|
||||||
|
const released = buffered.slice(0, releaseLength);
|
||||||
|
const pending = buffered.slice(releaseLength);
|
||||||
|
|
||||||
|
if (pending) {
|
||||||
|
buffers.set(client.id, pending);
|
||||||
|
} else {
|
||||||
|
buffers.delete(client.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (released) {
|
||||||
|
client.emit(eventName, {
|
||||||
|
conversationId,
|
||||||
|
text: redactSensitiveContent(released).content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private safeRedactionPrefixLength(content: string): number {
|
||||||
|
let retainedFrom = content.length;
|
||||||
|
|
||||||
|
// Retain the current token because it may become a split secret or email.
|
||||||
|
const token = /(?:^|\s)(\S*)$/.exec(content);
|
||||||
|
if (token) {
|
||||||
|
const matched = token[0] ?? '';
|
||||||
|
const trailingToken = token[1] ?? '';
|
||||||
|
retainedFrom = token.index + matched.length - trailingToken.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The secret classifier accepts whitespace around ':' and '=', so preserve
|
||||||
|
// a pending label until its value and delimiter are both complete.
|
||||||
|
const pendingSecretLabel =
|
||||||
|
/(?:^|[^A-Za-z0-9_])((?:api[_-]?key|token|password|secret|bearer|authorization)\s*)$/i.exec(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
if (pendingSecretLabel) {
|
||||||
|
const label = pendingSecretLabel[1] ?? '';
|
||||||
|
retainedFrom = Math.min(
|
||||||
|
retainedFrom,
|
||||||
|
pendingSecretLabel.index + pendingSecretLabel[0].length - label.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretLabel = /(?:api[_-]?key|token|password|secret|authorization)\s*[:=]\s*$/i.exec(
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
if (secretLabel) {
|
||||||
|
retainedFrom = Math.min(retainedFrom, secretLabel.index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone numbers can contain whitespace and punctuation; preserve the full
|
||||||
|
// trailing numeric candidate until a non-phone character establishes a boundary.
|
||||||
|
const phone = /(?:^|[^A-Za-z0-9_])(\+?\d[\d(). -]*)$/.exec(content);
|
||||||
|
if (phone) {
|
||||||
|
const matched = phone[0] ?? '';
|
||||||
|
const trailingPhoneCandidate = phone[1] ?? '';
|
||||||
|
retainedFrom = Math.min(
|
||||||
|
retainedFrom,
|
||||||
|
phone.index + matched.length - trailingPhoneCandidate.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const privateKeyStart = content.lastIndexOf('-----BEGIN');
|
||||||
|
if (privateKeyStart >= 0) {
|
||||||
|
const privateKey = content.slice(privateKeyStart);
|
||||||
|
if (/-----END(?: [A-Z]+)* KEY-----/.test(privateKey)) {
|
||||||
|
// Release the complete block in one pass so the full-block classifier can redact it.
|
||||||
|
retainedFrom = content.length;
|
||||||
|
} else {
|
||||||
|
retainedFrom = Math.min(retainedFrom, privateKeyStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retainedFrom;
|
||||||
|
}
|
||||||
|
|
||||||
|
private egressKey(client: Socket, eventName: 'agent:text' | 'agent:thinking'): string {
|
||||||
|
return `${client.id}:${eventName}`;
|
||||||
|
}
|
||||||
|
|
||||||
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
private relayEvent(client: Socket, conversationId: string, event: AgentSessionEvent): void {
|
||||||
if (!client.connected) {
|
if (!client.connected) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -544,13 +893,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
cs.toolCalls = [];
|
cs.toolCalls = [];
|
||||||
cs.pendingToolCalls.clear();
|
cs.pendingToolCalls.clear();
|
||||||
}
|
}
|
||||||
|
this.textEgressBuffers.set(client.id, '');
|
||||||
|
this.thinkingEgressBuffers.set(client.id, '');
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:text'));
|
||||||
|
this.overflowedEgress.delete(this.egressKey(client, 'agent:thinking'));
|
||||||
client.emit('agent:start', { conversationId });
|
client.emit('agent:start', { conversationId });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'agent_end': {
|
case 'agent_end': {
|
||||||
// Gather usage stats from the Pi session
|
// 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 piSession = agentSession?.piSession;
|
||||||
const stats = piSession?.getSessionStats();
|
const stats = piSession?.getSessionStats();
|
||||||
const contextUsage = piSession?.getContextUsage();
|
const contextUsage = piSession?.getContextUsage();
|
||||||
@@ -569,6 +925,20 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
this.flushRedactedEgress(
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
'agent:text',
|
||||||
|
this.textEgressBuffers,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.flushRedactedEgress(
|
||||||
|
client,
|
||||||
|
conversationId,
|
||||||
|
'agent:thinking',
|
||||||
|
this.thinkingEgressBuffers,
|
||||||
|
true,
|
||||||
|
);
|
||||||
client.emit('agent:end', {
|
client.emit('agent:end', {
|
||||||
conversationId,
|
conversationId,
|
||||||
usage: usagePayload,
|
usage: usagePayload,
|
||||||
@@ -611,8 +981,11 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
{
|
{
|
||||||
conversationId,
|
conversationId,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: cs.assistantText,
|
content: redactSensitiveContent(cs.assistantText).content,
|
||||||
metadata,
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
classifications: redactSensitiveContent(cs.assistantText).classifications,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
userId,
|
userId,
|
||||||
)
|
)
|
||||||
@@ -634,20 +1007,26 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
case 'message_update': {
|
case 'message_update': {
|
||||||
const assistantEvent = event.assistantMessageEvent;
|
const assistantEvent = event.assistantMessageEvent;
|
||||||
if (assistantEvent.type === 'text_delta') {
|
if (assistantEvent.type === 'text_delta') {
|
||||||
// Accumulate assistant text for persistence
|
// Keep raw stream material in memory only; persist and emit only redacted text.
|
||||||
const cs = this.clientSessions.get(client.id);
|
const cs = this.clientSessions.get(client.id);
|
||||||
if (cs) {
|
if (cs) {
|
||||||
cs.assistantText += assistantEvent.delta;
|
cs.assistantText += assistantEvent.delta;
|
||||||
}
|
}
|
||||||
client.emit('agent:text', {
|
this.appendAndFlushRedactedEgress(
|
||||||
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: assistantEvent.delta,
|
'agent:text',
|
||||||
});
|
this.textEgressBuffers,
|
||||||
|
assistantEvent.delta,
|
||||||
|
);
|
||||||
} else if (assistantEvent.type === 'thinking_delta') {
|
} else if (assistantEvent.type === 'thinking_delta') {
|
||||||
client.emit('agent:thinking', {
|
this.appendAndFlushRedactedEgress(
|
||||||
|
client,
|
||||||
conversationId,
|
conversationId,
|
||||||
text: assistantEvent.delta,
|
'agent:thinking',
|
||||||
});
|
this.thinkingEgressBuffers,
|
||||||
|
assistantEvent.delta,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
|
|
||||||
|
const adminCommand: CommandDef = {
|
||||||
|
name: 'gc',
|
||||||
|
description: 'GC',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'admin',
|
||||||
|
execution: 'socket',
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||||
|
|
||||||
|
function createService(role: string): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
entries.set(key, value);
|
||||||
|
},
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
};
|
||||||
|
return new CommandAuthorizationService(db as never, redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommandAuthorizationService', () => {
|
||||||
|
it('consumes one exact actor-bound approval once', async (): Promise<void> => {
|
||||||
|
const service = createService('admin');
|
||||||
|
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, payload, 'admin-1', approval!.approvalId)).allowed,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an approval when the structured action is mutated', async (): Promise<void> => {
|
||||||
|
const service = createService('admin');
|
||||||
|
const approval = await service.createApproval(adminCommand, payload, 'admin-1');
|
||||||
|
const mutated = { ...payload, conversationId: 'other-conversation' };
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, mutated, 'admin-1', approval!.approvalId)).allowed,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies an admin command to a member before approval is considered', async (): Promise<void> => {
|
||||||
|
const service = createService('member');
|
||||||
|
const approval = await service.createApproval(adminCommand, payload, 'member-1');
|
||||||
|
expect(approval).toBeNull();
|
||||||
|
expect(
|
||||||
|
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
apps/gateway/src/commands/command-authorization.service.ts
Normal file
138
apps/gateway/src/commands/command-authorization.service.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { eq, users as usersTable, type Db } from '@mosaicstack/db';
|
||||||
|
import type { CommandDef, SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { DB } from '../database/database.module.js';
|
||||||
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
|
||||||
|
export type CommandRole = 'admin' | 'member' | 'viewer';
|
||||||
|
|
||||||
|
export interface CommandApproval {
|
||||||
|
approvalId: string;
|
||||||
|
actionDigest: string;
|
||||||
|
actorId: string;
|
||||||
|
command: string;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandAuthorizationResult {
|
||||||
|
allowed: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommandAuthorizationService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DB) private readonly db: Db,
|
||||||
|
@Inject(COMMANDS_REDIS)
|
||||||
|
private readonly redis: {
|
||||||
|
get(key: string): Promise<string | null>;
|
||||||
|
set(key: string, value: string, ...args: string[]): Promise<unknown>;
|
||||||
|
del(key: string): Promise<number>;
|
||||||
|
},
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async authorize(
|
||||||
|
command: CommandDef,
|
||||||
|
payload: SlashCommandPayload,
|
||||||
|
actorId: string,
|
||||||
|
approvalId?: string,
|
||||||
|
): Promise<CommandAuthorizationResult> {
|
||||||
|
const role = await this.resolveRole(actorId);
|
||||||
|
if (!role || !this.hasScope(role, command.scope)) {
|
||||||
|
return { allowed: false, reason: 'not authorized for this command scope' };
|
||||||
|
}
|
||||||
|
if (command.scope !== 'admin') return { allowed: true };
|
||||||
|
if (!approvalId) return { allowed: false, reason: 'durable approval is required' };
|
||||||
|
const actionDigest = this.actionDigest(command.name, payload);
|
||||||
|
const approved = await this.consumeApproval(approvalId, actorId, actionDigest);
|
||||||
|
return approved
|
||||||
|
? { allowed: true }
|
||||||
|
: {
|
||||||
|
allowed: false,
|
||||||
|
reason: 'approval is invalid, expired, replayed, or does not match this action',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createApproval(
|
||||||
|
command: CommandDef,
|
||||||
|
payload: SlashCommandPayload,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<CommandApproval | null> {
|
||||||
|
const role = await this.resolveRole(actorId);
|
||||||
|
if (!role || command.scope !== 'admin' || !this.hasScope(role, command.scope)) return null;
|
||||||
|
|
||||||
|
const approvalId = randomUUID();
|
||||||
|
const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||||
|
const approval: CommandApproval = {
|
||||||
|
approvalId,
|
||||||
|
actionDigest: this.actionDigest(command.name, payload),
|
||||||
|
actorId,
|
||||||
|
command: command.name,
|
||||||
|
expiresAt,
|
||||||
|
};
|
||||||
|
await this.redis.set(this.key(approvalId), JSON.stringify(approval), 'EX', '300');
|
||||||
|
return approval;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||||
|
const [user] = await this.db
|
||||||
|
.select({ role: usersTable.role })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, actorId))
|
||||||
|
.limit(1);
|
||||||
|
const role = user?.role;
|
||||||
|
return role === 'admin' || role === 'member' || role === 'viewer' ? role : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasScope(role: CommandRole, scope: CommandDef['scope']): boolean {
|
||||||
|
if (role === 'admin') return true;
|
||||||
|
return role === 'member' && (scope === 'core' || scope === 'agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumeApproval(
|
||||||
|
approvalId: string,
|
||||||
|
actorId: string,
|
||||||
|
actionDigest: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const key = this.key(approvalId);
|
||||||
|
const encoded = await this.redis.get(key);
|
||||||
|
if (!encoded) return false;
|
||||||
|
const parsed: unknown = JSON.parse(encoded);
|
||||||
|
if (
|
||||||
|
!this.isApproval(parsed) ||
|
||||||
|
parsed.actorId !== actorId ||
|
||||||
|
parsed.actionDigest !== actionDigest ||
|
||||||
|
Date.parse(parsed.expiresAt) <= Date.now()
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
return (await this.redis.del(key)) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private actionDigest(command: string, payload: SlashCommandPayload): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
command,
|
||||||
|
args: payload.args?.trim() ?? '',
|
||||||
|
conversationId: payload.conversationId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isApproval(value: unknown): value is CommandApproval {
|
||||||
|
return (
|
||||||
|
typeof value === 'object' &&
|
||||||
|
value !== null &&
|
||||||
|
'approvalId' in value &&
|
||||||
|
'actionDigest' in value &&
|
||||||
|
'actorId' in value &&
|
||||||
|
'expiresAt' in value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(approvalId: string): string {
|
||||||
|
return `tess:command-approval:${approvalId}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -89,6 +89,7 @@ function buildService(): CommandExecutorService {
|
|||||||
describe('CommandExecutorService — P8-012 commands', () => {
|
describe('CommandExecutorService — P8-012 commands', () => {
|
||||||
let service: CommandExecutorService;
|
let service: CommandExecutorService;
|
||||||
const userId = 'user-123';
|
const userId = 'user-123';
|
||||||
|
const userScope = { userId, tenantId: userId };
|
||||||
const conversationId = 'conv-456';
|
const conversationId = 'conv-456';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -99,31 +100,26 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider login — missing provider name
|
// /provider login — missing provider name
|
||||||
it('/provider login with no provider name returns usage error', async () => {
|
it('/provider login with no provider name returns usage error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'login', conversationId };
|
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.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider login');
|
expect(result.message).toContain('Usage: /provider login');
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
|
|
||||||
// /provider login anthropic — success with URL containing poll token
|
// /provider login anthropic — no bearer token or auth URL reaches chat output
|
||||||
it('/provider login <name> returns success with URL and poll token', async () => {
|
it('/provider login <name> keeps its one-time token out of chat output', async () => {
|
||||||
const payload: SlashCommandPayload = {
|
const payload: SlashCommandPayload = {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
args: 'login anthropic',
|
args: 'login anthropic',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
expect(result.message).toContain('anthropic');
|
expect(result.message).toContain('anthropic');
|
||||||
expect(result.message).toContain('http');
|
expect(result.message).not.toContain('http');
|
||||||
// data should contain loginUrl and pollToken
|
expect(result.message).not.toContain('token=');
|
||||||
expect(result.data).toBeDefined();
|
expect(result.data).toEqual({ provider: 'anthropic' });
|
||||||
const data = result.data as Record<string, unknown>;
|
|
||||||
expect(typeof data['loginUrl']).toBe('string');
|
|
||||||
expect(typeof data['pollToken']).toBe('string');
|
|
||||||
expect(data['loginUrl'] as string).toContain('anthropic');
|
|
||||||
expect(data['loginUrl'] as string).toContain(data['pollToken'] as string);
|
|
||||||
// Verify Valkey was called
|
// Verify Valkey was called
|
||||||
expect(mockRedis.set).toHaveBeenCalledOnce();
|
expect(mockRedis.set).toHaveBeenCalledOnce();
|
||||||
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
const [key, value, , ttl] = mockRedis.set.mock.calls[0] as [string, string, string, number];
|
||||||
@@ -138,7 +134,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider with no args — returns usage
|
// /provider with no args — returns usage
|
||||||
it('/provider with no args returns usage message', async () => {
|
it('/provider with no args returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /provider');
|
expect(result.message).toContain('Usage: /provider');
|
||||||
});
|
});
|
||||||
@@ -146,7 +142,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider list
|
// /provider list
|
||||||
it('/provider list returns success', async () => {
|
it('/provider list returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'list', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe('provider');
|
expect(result.command).toBe('provider');
|
||||||
});
|
});
|
||||||
@@ -154,7 +150,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /provider logout with no name — usage error
|
// /provider logout with no name — usage error
|
||||||
it('/provider logout with no name returns error', async () => {
|
it('/provider logout with no name returns error', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'provider', args: 'logout', conversationId };
|
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.success).toBe(false);
|
||||||
expect(result.message).toContain('Usage: /provider logout');
|
expect(result.message).toContain('Usage: /provider logout');
|
||||||
});
|
});
|
||||||
@@ -166,7 +162,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'unknown',
|
args: 'unknown',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.message).toContain('Unknown subcommand');
|
expect(result.message).toContain('Unknown subcommand');
|
||||||
});
|
});
|
||||||
@@ -174,7 +170,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission status
|
// /mission status
|
||||||
it('/mission status returns stub message', async () => {
|
it('/mission status returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', args: 'status', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe('mission');
|
expect(result.command).toBe('mission');
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
@@ -183,7 +179,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /mission with no args
|
// /mission with no args
|
||||||
it('/mission with no args returns status stub', async () => {
|
it('/mission with no args returns status stub', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'mission', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.message).toContain('Mission status');
|
expect(result.message).toContain('Mission status');
|
||||||
});
|
});
|
||||||
@@ -195,7 +191,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'set my-mission-123',
|
args: 'set my-mission-123',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-mission-123');
|
expect(result.message).toContain('my-mission-123');
|
||||||
});
|
});
|
||||||
@@ -203,7 +199,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent list
|
// /agent list
|
||||||
it('/agent list returns stub message', async () => {
|
it('/agent list returns stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', args: 'list', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe('agent');
|
expect(result.command).toBe('agent');
|
||||||
expect(result.message).toContain('agent');
|
expect(result.message).toContain('agent');
|
||||||
@@ -212,7 +208,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /agent with no args
|
// /agent with no args
|
||||||
it('/agent with no args returns usage', async () => {
|
it('/agent with no args returns usage', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'agent', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage: /agent');
|
expect(result.message).toContain('Usage: /agent');
|
||||||
});
|
});
|
||||||
@@ -224,7 +220,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
args: 'my-agent-id',
|
args: 'my-agent-id',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await service.execute(payload, userId);
|
const result = await service.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('my-agent-id');
|
expect(result.message).toContain('my-agent-id');
|
||||||
});
|
});
|
||||||
@@ -232,7 +228,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /prdy
|
// /prdy
|
||||||
it('/prdy returns PRD wizard message', async () => {
|
it('/prdy returns PRD wizard message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'prdy', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe('prdy');
|
expect(result.command).toBe('prdy');
|
||||||
expect(result.message).toContain('mosaic prdy');
|
expect(result.message).toContain('mosaic prdy');
|
||||||
@@ -241,7 +237,7 @@ describe('CommandExecutorService — P8-012 commands', () => {
|
|||||||
// /tools
|
// /tools
|
||||||
it('/tools returns tools stub message', async () => {
|
it('/tools returns tools stub message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'tools', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe('tools');
|
expect(result.command).toBe('tools');
|
||||||
expect(result.message).toContain('tools');
|
expect(result.message).toContain('tools');
|
||||||
|
|||||||
109
apps/gateway/src/commands/command-executor-tess-security.spec.ts
Normal file
109
apps/gateway/src/commands/command-executor-tess-security.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { SlashCommandPayload } from '@mosaicstack/types';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
|
|
||||||
|
const registry = {
|
||||||
|
getManifest: vi.fn(() => ({
|
||||||
|
version: 1,
|
||||||
|
commands: [
|
||||||
|
{
|
||||||
|
name: 'gc',
|
||||||
|
description: 'System-wide garbage collection',
|
||||||
|
aliases: [],
|
||||||
|
scope: 'admin' as const,
|
||||||
|
execution: 'socket' as const,
|
||||||
|
available: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
skills: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionGc = {
|
||||||
|
sweepOrphans: vi.fn().mockResolvedValue({ orphanedSessions: 1, totalCleaned: [], duration: 1 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const scope = (userId: string) => ({ userId, tenantId: 'tenant-1' });
|
||||||
|
|
||||||
|
const authorization = {
|
||||||
|
authorize: vi.fn((_command: unknown, _payload: unknown, actorId: string) =>
|
||||||
|
Promise.resolve(
|
||||||
|
actorId === 'member-1'
|
||||||
|
? { allowed: false, reason: 'durable approval is required' }
|
||||||
|
: { allowed: false, reason: 'not authorized for this command scope' },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildExecutor(authorizationService: unknown = authorization): CommandExecutorService {
|
||||||
|
return new CommandExecutorService(
|
||||||
|
registry as never,
|
||||||
|
{ getSession: vi.fn() } as never,
|
||||||
|
{ clear: vi.fn(), set: vi.fn() } as never,
|
||||||
|
sessionGc as never,
|
||||||
|
{ set: vi.fn() } as never,
|
||||||
|
{ agents: {} } as never,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
authorizationService as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDurableAuthorization(): CommandAuthorizationService {
|
||||||
|
const entries = new Map<string, string>();
|
||||||
|
const db = {
|
||||||
|
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }) }),
|
||||||
|
};
|
||||||
|
const redis = {
|
||||||
|
get: async (key: string) => entries.get(key) ?? null,
|
||||||
|
set: async (key: string, value: string) => {
|
||||||
|
entries.set(key, value);
|
||||||
|
},
|
||||||
|
del: async (key: string) => Number(entries.delete(key)),
|
||||||
|
};
|
||||||
|
return new CommandAuthorizationService(db as never, redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TESS-M1-SEC-001 command authorization abuse cases', () => {
|
||||||
|
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||||
|
|
||||||
|
beforeEach((): void => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a forged admin identity and does not execute a system-wide command', async (): Promise<void> => {
|
||||||
|
const result = await buildExecutor().execute(payload, scope('admin-forged-by-client'));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('not authorized');
|
||||||
|
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies a privileged command without a server-bound durable approval', async (): Promise<void> => {
|
||||||
|
const result = await buildExecutor().execute(payload, scope('member-1'));
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.message).toContain('approval');
|
||||||
|
expect(sessionGc.sweepOrphans).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('executes an admin command only after a valid durable approval is issued and supplied', async (): Promise<void> => {
|
||||||
|
const executor = buildExecutor(createDurableAuthorization());
|
||||||
|
|
||||||
|
const adminScope = scope('admin-1');
|
||||||
|
const denied = await executor.execute(payload, adminScope);
|
||||||
|
const approval = await executor.createApproval(payload, adminScope);
|
||||||
|
const approved = await executor.execute(
|
||||||
|
{ ...payload, approvalId: approval?.approvalId },
|
||||||
|
adminScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(denied.success).toBe(false);
|
||||||
|
expect(denied.message).toContain('approval');
|
||||||
|
expect(approval).not.toBeNull();
|
||||||
|
expect(approved.success).toBe(true);
|
||||||
|
expect(sessionGc.sweepOrphans).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import type { QueueHandle } from '@mosaicstack/queue';
|
|||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
import type { SlashCommandPayload, SlashCommandResultPayload } from '@mosaicstack/types';
|
||||||
import { AgentService } from '../agent/agent.service.js';
|
import { AgentService } from '../agent/agent.service.js';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||||
import { SessionGCService } from '../gc/session-gc.service.js';
|
import { SessionGCService } from '../gc/session-gc.service.js';
|
||||||
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
import { SystemOverrideService } from '../preferences/system-override.service.js';
|
||||||
@@ -10,6 +11,7 @@ import { ReloadService } from '../reload/reload.service.js';
|
|||||||
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
import { McpClientService } from '../mcp-client/mcp-client.service.js';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -32,10 +34,17 @@ export class CommandExecutorService {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@Inject(McpClientService)
|
@Inject(McpClientService)
|
||||||
private readonly mcpClient: McpClientService | null,
|
private readonly mcpClient: McpClientService | null,
|
||||||
|
@Optional()
|
||||||
|
@Inject(CommandAuthorizationService)
|
||||||
|
private readonly authorization: CommandAuthorizationService | null = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(payload: SlashCommandPayload, userId: string): Promise<SlashCommandResultPayload> {
|
async execute(
|
||||||
|
payload: SlashCommandPayload,
|
||||||
|
scope: ActorTenantScope,
|
||||||
|
): Promise<SlashCommandResultPayload> {
|
||||||
const { command, args, conversationId } = payload;
|
const { command, args, conversationId } = payload;
|
||||||
|
const userId = scope.userId;
|
||||||
|
|
||||||
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
const def = this.registry.getManifest().commands.find((c) => c.name === command);
|
||||||
if (!def) {
|
if (!def) {
|
||||||
@@ -47,14 +56,24 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const authorization = await this.authorization?.authorize(
|
||||||
|
def,
|
||||||
|
payload,
|
||||||
|
userId,
|
||||||
|
payload.approvalId,
|
||||||
|
);
|
||||||
|
if (authorization && !authorization.allowed) {
|
||||||
|
return { command, conversationId, success: false, message: authorization.reason };
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (command) {
|
switch (command) {
|
||||||
case 'model':
|
case 'model':
|
||||||
return await this.handleModel(args ?? null, conversationId);
|
return await this.handleModel(args ?? null, conversationId, scope);
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
return await this.handleThinking(args ?? null, conversationId);
|
return await this.handleThinking(args ?? null, conversationId);
|
||||||
case 'system':
|
case 'system':
|
||||||
return await this.handleSystem(args ?? null, conversationId);
|
return await this.handleSystem(args ?? null, conversationId, scope);
|
||||||
case 'new':
|
case 'new':
|
||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
@@ -94,7 +113,7 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return await this.handleAgent(args ?? null, conversationId, userId);
|
return await this.handleAgent(args ?? null, conversationId, scope);
|
||||||
case 'provider':
|
case 'provider':
|
||||||
return await this.handleProvider(args ?? null, userId, conversationId);
|
return await this.handleProvider(args ?? null, userId, conversationId);
|
||||||
case 'mission':
|
case 'mission':
|
||||||
@@ -143,13 +162,22 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createApproval(payload: SlashCommandPayload, scope: ActorTenantScope) {
|
||||||
|
const def = this.registry
|
||||||
|
.getManifest()
|
||||||
|
.commands.find((command) => command.name === payload.command);
|
||||||
|
if (!def || !this.authorization) return null;
|
||||||
|
return this.authorization.createApproval(def, payload, scope.userId);
|
||||||
|
}
|
||||||
|
|
||||||
private async handleModel(
|
private async handleModel(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Show current override or usage hint
|
// Show current override or usage hint
|
||||||
const currentOverride = this.chatGateway?.getModelOverride(conversationId);
|
const currentOverride = this.chatGateway?.getModelOverride(conversationId, scope);
|
||||||
if (currentOverride) {
|
if (currentOverride) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -171,7 +199,7 @@ export class CommandExecutorService {
|
|||||||
|
|
||||||
// /model clear removes the override and re-enables automatic routing
|
// /model clear removes the override and re-enables automatic routing
|
||||||
if (modelName === 'clear') {
|
if (modelName === 'clear') {
|
||||||
this.chatGateway?.setModelOverride(conversationId, null);
|
this.chatGateway?.setModelOverride(conversationId, null, scope);
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -181,9 +209,9 @@ export class CommandExecutorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set the sticky per-session override (M4-007)
|
// 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) {
|
if (!session) {
|
||||||
return {
|
return {
|
||||||
command: 'model',
|
command: 'model',
|
||||||
@@ -224,10 +252,11 @@ export class CommandExecutorService {
|
|||||||
private async handleSystem(
|
private async handleSystem(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
|
scope: ActorTenantScope,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
if (!args || args.trim().length === 0) {
|
if (!args || args.trim().length === 0) {
|
||||||
// Clear the override when called with no args
|
// Clear the override when called with no args
|
||||||
await this.systemOverride.clear(conversationId);
|
await this.systemOverride.clear(conversationId, scope);
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -236,7 +265,7 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.systemOverride.set(conversationId, args.trim());
|
await this.systemOverride.set(conversationId, args.trim(), scope);
|
||||||
return {
|
return {
|
||||||
command: 'system',
|
command: 'system',
|
||||||
conversationId,
|
conversationId,
|
||||||
@@ -248,8 +277,9 @@ export class CommandExecutorService {
|
|||||||
private async handleAgent(
|
private async handleAgent(
|
||||||
args: string | null,
|
args: string | null,
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
userId: string,
|
scope: ActorTenantScope,
|
||||||
): Promise<SlashCommandResultPayload> {
|
): Promise<SlashCommandResultPayload> {
|
||||||
|
const userId = scope.userId;
|
||||||
if (!args) {
|
if (!args) {
|
||||||
return {
|
return {
|
||||||
command: 'agent',
|
command: 'agent',
|
||||||
@@ -338,11 +368,14 @@ export class CommandExecutorService {
|
|||||||
conversationId,
|
conversationId,
|
||||||
agentConfig.id,
|
agentConfig.id,
|
||||||
agentConfig.name,
|
agentConfig.name,
|
||||||
|
scope,
|
||||||
agentConfig.model ?? undefined,
|
agentConfig.model ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Broadcast updated session:info so TUI TopBar reflects new agent/model
|
// 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(
|
this.logger.log(
|
||||||
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
`Agent switched to "${agentConfig.name}" (${agentConfig.id}) for conversation ${conversationId} (M5-003)`,
|
||||||
@@ -403,22 +436,28 @@ export class CommandExecutorService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const pollToken = crypto.randomUUID();
|
const pollToken = crypto.randomUUID();
|
||||||
const key = `mosaic:auth:poll:${pollToken}`;
|
const tokenDigest = await crypto.subtle.digest(
|
||||||
// Store pending state in Valkey (TTL 5 minutes)
|
'SHA-256',
|
||||||
|
new TextEncoder().encode(pollToken),
|
||||||
|
);
|
||||||
|
const tokenHash = Array.from(new Uint8Array(tokenDigest), (byte: number): string =>
|
||||||
|
byte.toString(16).padStart(2, '0'),
|
||||||
|
).join('');
|
||||||
|
const key = `mosaic:auth:poll:${tokenHash}`;
|
||||||
|
// Persist only a short-lived token digest. The raw token is delivered only by
|
||||||
|
// the authenticated dashboard flow, never in chat output or command metadata.
|
||||||
await this.redis.set(
|
await this.redis.set(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
JSON.stringify({ status: 'pending', provider: providerName, userId }),
|
||||||
'EX',
|
'EX',
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
// In production this would construct an OAuth URL
|
|
||||||
const loginUrl = `${process.env['MOSAIC_BASE_URL'] ?? 'http://localhost:3000'}/auth/provider/${providerName}?token=${pollToken}`;
|
|
||||||
return {
|
return {
|
||||||
command: 'provider',
|
command: 'provider',
|
||||||
success: true,
|
success: true,
|
||||||
message: `Open this URL to authenticate with ${providerName}:\n${loginUrl}`,
|
message: `Provider login for ${providerName} is ready. Continue in the authenticated dashboard.`,
|
||||||
conversationId,
|
conversationId,
|
||||||
data: { loginUrl, pollToken, provider: providerName },
|
data: { provider: providerName },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
let registry: CommandRegistryService;
|
let registry: CommandRegistryService;
|
||||||
let executor: CommandExecutorService;
|
let executor: CommandExecutorService;
|
||||||
const userId = 'user-integ-001';
|
const userId = 'user-integ-001';
|
||||||
|
const userScope = { userId, tenantId: userId };
|
||||||
const conversationId = 'conv-integ-001';
|
const conversationId = 'conv-integ-001';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -170,7 +171,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// Unknown command returns error
|
// Unknown command returns error
|
||||||
it('unknown command returns success:false with descriptive message', async () => {
|
it('unknown command returns success:false with descriptive message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'nonexistent', conversationId };
|
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.success).toBe(false);
|
||||||
expect(result.message).toContain('nonexistent');
|
expect(result.message).toContain('nonexistent');
|
||||||
expect(result.command).toBe('nonexistent');
|
expect(result.command).toBe('nonexistent');
|
||||||
@@ -179,7 +180,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
// /gc handler calls SessionGCService.sweepOrphans (admin-only, no userId arg)
|
||||||
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
it('/gc calls SessionGCService.sweepOrphans without arguments', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'gc', conversationId };
|
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(mockSessionGC.sweepOrphans).toHaveBeenCalledWith();
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('GC sweep complete');
|
expect(result.message).toContain('GC sweep complete');
|
||||||
@@ -190,8 +191,8 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
it('/system with text calls SystemOverrideService.set', async () => {
|
it('/system with text calls SystemOverrideService.set', async () => {
|
||||||
const override = 'You are a helpful assistant.';
|
const override = 'You are a helpful assistant.';
|
||||||
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
const payload: SlashCommandPayload = { command: 'system', args: override, conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override);
|
expect(mockSystemOverride.set).toHaveBeenCalledWith(conversationId, override, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('override set');
|
expect(result.message).toContain('override set');
|
||||||
});
|
});
|
||||||
@@ -199,8 +200,8 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /system with no args clears the override
|
// /system with no args clears the override
|
||||||
it('/system with no args calls SystemOverrideService.clear', async () => {
|
it('/system with no args calls SystemOverrideService.clear', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
const payload: SlashCommandPayload = { command: 'system', conversationId };
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId);
|
expect(mockSystemOverride.clear).toHaveBeenCalledWith(conversationId, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.message).toContain('cleared');
|
expect(result.message).toContain('cleared');
|
||||||
});
|
});
|
||||||
@@ -212,7 +213,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
args: 'claude-3-opus',
|
args: 'claude-3-opus',
|
||||||
conversationId,
|
conversationId,
|
||||||
};
|
};
|
||||||
const result = await executor.execute(payload, userId);
|
const result = await executor.execute(payload, userScope);
|
||||||
expect(result.success).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
expect(result.command).toBe('model');
|
expect(result.command).toBe('model');
|
||||||
expect(result.message).toContain('claude-3-opus');
|
expect(result.message).toContain('claude-3-opus');
|
||||||
@@ -221,7 +222,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with valid level returns success
|
// /thinking with valid level returns success
|
||||||
it('/thinking with valid level returns success', async () => {
|
it('/thinking with valid level returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'high', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.message).toContain('high');
|
expect(result.message).toContain('high');
|
||||||
});
|
});
|
||||||
@@ -229,7 +230,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /thinking with invalid level returns usage message
|
// /thinking with invalid level returns usage message
|
||||||
it('/thinking with invalid level returns usage message', async () => {
|
it('/thinking with invalid level returns usage message', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'thinking', args: 'invalid', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.message).toContain('Usage:');
|
expect(result.message).toContain('Usage:');
|
||||||
});
|
});
|
||||||
@@ -237,7 +238,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /new command returns success
|
// /new command returns success
|
||||||
it('/new returns success', async () => {
|
it('/new returns success', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'new', conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe('new');
|
expect(result.command).toBe('new');
|
||||||
});
|
});
|
||||||
@@ -245,7 +246,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
// /reload without reloadService returns failure
|
// /reload without reloadService returns failure
|
||||||
it('/reload without ReloadService returns failure', async () => {
|
it('/reload without ReloadService returns failure', async () => {
|
||||||
const payload: SlashCommandPayload = { command: 'reload', conversationId };
|
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.success).toBe(false);
|
||||||
expect(result.message).toContain('ReloadService');
|
expect(result.message).toContain('ReloadService');
|
||||||
});
|
});
|
||||||
@@ -255,7 +256,7 @@ describe('CommandExecutorService — integration', () => {
|
|||||||
for (const cmd of stubCommands) {
|
for (const cmd of stubCommands) {
|
||||||
it(`/${cmd} returns success (stub)`, async () => {
|
it(`/${cmd} returns success (stub)`, async () => {
|
||||||
const payload: SlashCommandPayload = { command: cmd, conversationId };
|
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.success).toBe(true);
|
||||||
expect(result.command).toBe(cmd);
|
expect(result.command).toBe(cmd);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
|||||||
import { ChatModule } from '../chat/chat.module.js';
|
import { ChatModule } from '../chat/chat.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
import { ReloadModule } from '../reload/reload.module.js';
|
import { ReloadModule } from '../reload/reload.module.js';
|
||||||
|
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||||
import { CommandExecutorService } from './command-executor.service.js';
|
import { CommandExecutorService } from './command-executor.service.js';
|
||||||
import { CommandRegistryService } from './command-registry.service.js';
|
import { CommandRegistryService } from './command-registry.service.js';
|
||||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||||
@@ -24,6 +25,7 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
|||||||
inject: [COMMANDS_QUEUE_HANDLE],
|
inject: [COMMANDS_QUEUE_HANDLE],
|
||||||
},
|
},
|
||||||
CommandRegistryService,
|
CommandRegistryService,
|
||||||
|
CommandAuthorizationService,
|
||||||
CommandExecutorService,
|
CommandExecutorService,
|
||||||
],
|
],
|
||||||
exports: [CommandRegistryService, CommandExecutorService],
|
exports: [CommandRegistryService, CommandExecutorService],
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { CapabilitiesController } from './server/verbs/capabilities.controller.j
|
|||||||
import { GrantsService } from './grants.service.js';
|
import { GrantsService } from './grants.service.js';
|
||||||
import { FederationClientService, QuerySourceService } from './client/index.js';
|
import { FederationClientService, QuerySourceService } from './client/index.js';
|
||||||
import { FederationAuthGuard, FederationScopeService } from './server/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({
|
@Module({
|
||||||
controllers: [EnrollmentController, FederationController, CapabilitiesController],
|
controllers: [EnrollmentController, FederationController, CapabilitiesController, ListController],
|
||||||
providers: [
|
providers: [
|
||||||
AdminGuard,
|
AdminGuard,
|
||||||
CaService,
|
CaService,
|
||||||
@@ -20,6 +22,7 @@ import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
|||||||
QuerySourceService,
|
QuerySourceService,
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
|
FederationListQueryService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
CaService,
|
CaService,
|
||||||
@@ -29,6 +32,7 @@ import { FederationAuthGuard, FederationScopeService } from './server/index.js';
|
|||||||
QuerySourceService,
|
QuerySourceService,
|
||||||
FederationAuthGuard,
|
FederationAuthGuard,
|
||||||
FederationScopeService,
|
FederationScopeService,
|
||||||
|
FederationListQueryService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FederationModule {}
|
export class FederationModule {}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal file
408
apps/gateway/src/federation/server/verbs/list-query.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal file
147
apps/gateway/src/federation/server/verbs/list.controller.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { fromNodeHeaders } from 'better-auth/node';
|
import { fromNodeHeaders } from 'better-auth/node';
|
||||||
import type { Auth } from '@mosaicstack/auth';
|
import type { Auth } from '@mosaicstack/auth';
|
||||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
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';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,14 +71,25 @@ async function handleMcpRequest(
|
|||||||
return;
|
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 ─────────────────────────────────────────────────────
|
// ─── Session routing ─────────────────────────────────────────────────────
|
||||||
const sessionId = req.raw.headers['mcp-session-id'];
|
const sessionId = req.raw.headers['mcp-session-id'];
|
||||||
|
|
||||||
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
||||||
// Existing session request
|
// Existing session request
|
||||||
const transport = mcpService.getSession(sessionId);
|
const transport = mcpService.getSession(sessionId, actor);
|
||||||
if (!transport) {
|
if (!transport) {
|
||||||
logger.warn(`MCP session not found: ${sessionId}`);
|
logger.warn(`MCP session not found: ${sessionId}`);
|
||||||
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
reply.raw.writeHead(404, { 'Content-Type': 'application/json' });
|
||||||
@@ -112,8 +127,10 @@ async function handleMcpRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create new session and handle this initializing request
|
// Create new session and handle this initializing request
|
||||||
const { transport } = mcpService.createSession(userId);
|
const { transport } = mcpService.createSession(actor);
|
||||||
logger.log(`New MCP session created for user ${userId}`);
|
logger.log(
|
||||||
|
`New MCP session created for actor=${actor.userId} tenant=${actor.tenantId} correlation=${actor.correlationId}`,
|
||||||
|
);
|
||||||
|
|
||||||
await transport.handleRequest(req.raw, reply.raw, body);
|
await transport.handleRequest(req.raw, reply.raw, body);
|
||||||
}
|
}
|
||||||
|
|||||||
461
apps/gateway/src/mcp/mcp.service.spec.ts
Normal file
461
apps/gateway/src/mcp/mcp.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,11 +10,216 @@ import { MEMORY } from '../memory/memory.tokens.js';
|
|||||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.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 {
|
interface SessionEntry {
|
||||||
server: McpServer;
|
server: McpServer;
|
||||||
transport: StreamableHTTPServerTransport;
|
transport: StreamableHTTPServerTransport;
|
||||||
createdAt: Date;
|
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;
|
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()
|
@Injectable()
|
||||||
@@ -33,13 +238,18 @@ export class McpService implements OnModuleDestroy {
|
|||||||
* Creates a new MCP session with its own server + transport pair.
|
* Creates a new MCP session with its own server + transport pair.
|
||||||
* Returns the transport for use by the controller.
|
* 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 sessionId = randomUUID();
|
||||||
|
|
||||||
const transport = new StreamableHTTPServerTransport({
|
const transport = new StreamableHTTPServerTransport({
|
||||||
sessionIdGenerator: () => sessionId,
|
sessionIdGenerator: () => sessionId,
|
||||||
onsessioninitialized: (id) => {
|
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: {} } },
|
{ capabilities: { tools: {} } },
|
||||||
);
|
);
|
||||||
|
|
||||||
this.registerTools(server, userId);
|
this.registerTools(server, actor);
|
||||||
|
|
||||||
transport.onclose = () => {
|
transport.onclose = () => {
|
||||||
this.logger.log(`MCP session closed: ${sessionId}`);
|
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 };
|
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 {
|
getSession(sessionId: string, actor: McpActorContext): StreamableHTTPServerTransport | null {
|
||||||
return this.sessions.get(sessionId)?.transport ?? 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.
|
* 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 ────────────────────────────────────────────
|
// ─── Brain: Project tools ────────────────────────────────────────────
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'brain_list_projects',
|
'brain_list_projects',
|
||||||
{
|
{
|
||||||
description: 'List all projects in the brain.',
|
description: 'List all projects in the brain.',
|
||||||
inputSchema: z.object({}),
|
inputSchema: strictObject({}),
|
||||||
},
|
},
|
||||||
async () => {
|
async (params) => {
|
||||||
const projects = await this.brain.projects.findAll();
|
assertMcpToolAuthorized(actor, 'brain_list_projects', params);
|
||||||
|
const projects = filterProjectsForActor(
|
||||||
|
actor,
|
||||||
|
(await this.brain.projects.findAll()) as ProjectLike[],
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(projects, null, 2) }],
|
||||||
};
|
};
|
||||||
@@ -96,17 +401,21 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_get_project',
|
'brain_get_project',
|
||||||
{
|
{
|
||||||
description: 'Get a project by ID.',
|
description: 'Get a project by ID.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
id: z.string().describe('Project ID (UUID)'),
|
id: z.string().describe('Project ID (UUID)'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ id }) => {
|
async ({ id, ...params }) => {
|
||||||
const project = await this.brain.projects.findById(id);
|
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 {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
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',
|
'brain_list_tasks',
|
||||||
{
|
{
|
||||||
description: 'List tasks, optionally filtered by project, mission, or status.',
|
description: 'List tasks, optionally filtered by project, mission, or status.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
missionId: z.string().optional().describe('Filter by mission ID'),
|
missionId: z.string().optional().describe('Filter by mission ID'),
|
||||||
status: z.string().optional().describe('Filter by status'),
|
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';
|
type TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
let tasks;
|
let tasks;
|
||||||
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
if (projectId) tasks = await this.brain.tasks.findByProject(projectId);
|
||||||
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
else if (missionId) tasks = await this.brain.tasks.findByMission(missionId);
|
||||||
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
else if (status) tasks = await this.brain.tasks.findByStatus(status as TaskStatus);
|
||||||
else tasks = await this.brain.tasks.findAll();
|
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',
|
'brain_create_task',
|
||||||
{
|
{
|
||||||
description: 'Create a new task in the brain.',
|
description: 'Create a new task in the brain.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
title: z.string().describe('Task title'),
|
title: z.string().describe('Task title'),
|
||||||
description: z.string().optional().describe('Task description'),
|
description: z.string().optional().describe('Task description'),
|
||||||
projectId: z.string().optional().describe('Project ID'),
|
projectId: z.string().optional().describe('Project ID'),
|
||||||
@@ -149,6 +461,8 @@ export class McpService implements OnModuleDestroy {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async (params) => {
|
async (params) => {
|
||||||
|
assertMcpToolAuthorized(actor, 'brain_create_task', params);
|
||||||
|
await this.assertTaskCreateScopeAuthorized(actor, params);
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
const task = await this.brain.tasks.create({
|
const task = await this.brain.tasks.create({
|
||||||
...params,
|
...params,
|
||||||
@@ -162,7 +476,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_update_task',
|
'brain_update_task',
|
||||||
{
|
{
|
||||||
description: 'Update an existing task.',
|
description: 'Update an existing task.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
id: z.string().describe('Task ID'),
|
id: z.string().describe('Task ID'),
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
@@ -171,9 +485,17 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('not-started, in-progress, blocked, done, cancelled'),
|
.describe('not-started, in-progress, blocked, done, cancelled'),
|
||||||
priority: z.string().optional(),
|
priority: z.string().optional(),
|
||||||
|
projectId: z.string().optional().describe('Project ID'),
|
||||||
|
missionId: z.string().optional().describe('Mission ID'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ id, ...updates }) => {
|
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 TaskStatus = 'not-started' | 'in-progress' | 'blocked' | 'done' | 'cancelled';
|
||||||
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
type Priority = 'low' | 'medium' | 'high' | 'critical';
|
||||||
const task = await this.brain.tasks.update(id, {
|
const task = await this.brain.tasks.update(id, {
|
||||||
@@ -198,14 +520,19 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'brain_list_missions',
|
'brain_list_missions',
|
||||||
{
|
{
|
||||||
description: 'List all missions, optionally filtered by project.',
|
description: 'List all missions, optionally filtered by project.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
projectId: z.string().optional().describe('Filter by project ID'),
|
projectId: z.string().optional().describe('Filter by project ID'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
async ({ projectId }) => {
|
async (params) => {
|
||||||
const missions = projectId
|
assertMcpToolAuthorized(actor, 'brain_list_missions', params);
|
||||||
? await this.brain.missions.findByProject(projectId)
|
const { projectId } = params;
|
||||||
: await this.brain.missions.findAll();
|
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) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(missions, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -213,13 +540,12 @@ export class McpService implements OnModuleDestroy {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'brain_list_conversations',
|
'brain_list_conversations',
|
||||||
{
|
{
|
||||||
description: 'List conversations for a user.',
|
description: 'List conversations for the authenticated MCP actor.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({}),
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
async ({ userId }) => {
|
async (params) => {
|
||||||
const conversations = await this.brain.conversations.findAll(userId);
|
assertMcpToolAuthorized(actor, 'brain_list_conversations', params);
|
||||||
|
const conversations = await this.brain.conversations.findAll(actor.userId);
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
content: [{ type: 'text' as const, text: JSON.stringify(conversations, null, 2) }],
|
||||||
};
|
};
|
||||||
@@ -232,14 +558,15 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_search',
|
'memory_search',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Search across stored insights and knowledge using natural language. Returns semantically similar results.',
|
'Search stored insights and knowledge for the authenticated MCP actor using natural language.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
userId: z.string().describe('User ID to search memory for'),
|
|
||||||
query: z.string().describe('Natural language search query'),
|
query: z.string().describe('Natural language search query'),
|
||||||
limit: z.number().optional().describe('Max results (default 5)'),
|
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) {
|
if (!this.embeddings.available) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -251,7 +578,11 @@ export class McpService implements OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const embedding = await this.embeddings.embed(query);
|
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) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -259,20 +590,21 @@ export class McpService implements OnModuleDestroy {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'memory_get_preferences',
|
'memory_get_preferences',
|
||||||
{
|
{
|
||||||
description: 'Retrieve stored preferences for a user.',
|
description: 'Retrieve stored preferences for the authenticated MCP actor.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Filter by category: communication, coding, workflow, appearance, general'),
|
.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';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
const prefs = category
|
const prefs = category
|
||||||
? await this.memory.preferences.findByUserAndCategory(userId, category as Cat)
|
? await this.memory.preferences.findByUserAndCategory(actor.userId, category as Cat)
|
||||||
: await this.memory.preferences.findByUser(userId);
|
: await this.memory.preferences.findByUser(actor.userId);
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(prefs, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -281,9 +613,8 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_save_preference',
|
'memory_save_preference',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Store a learned user preference (e.g., "prefers tables over paragraphs", "timezone: America/Chicago").',
|
'Store a learned preference for the authenticated MCP actor (e.g., "prefers tables over paragraphs").',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
key: z.string().describe('Preference key'),
|
key: z.string().describe('Preference key'),
|
||||||
value: z.string().describe('Preference value (JSON string)'),
|
value: z.string().describe('Preference value (JSON string)'),
|
||||||
category: z
|
category: z
|
||||||
@@ -292,7 +623,9 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.describe('Category: communication, coding, workflow, appearance, general'),
|
.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';
|
type Cat = 'communication' | 'coding' | 'workflow' | 'appearance' | 'general';
|
||||||
let parsedValue: unknown;
|
let parsedValue: unknown;
|
||||||
try {
|
try {
|
||||||
@@ -301,7 +634,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
parsedValue = value;
|
parsedValue = value;
|
||||||
}
|
}
|
||||||
const pref = await this.memory.preferences.upsert({
|
const pref = await this.memory.preferences.upsert({
|
||||||
userId,
|
userId: actor.userId,
|
||||||
key,
|
key,
|
||||||
value: parsedValue,
|
value: parsedValue,
|
||||||
category: (category as Cat) ?? 'general',
|
category: (category as Cat) ?? 'general',
|
||||||
@@ -315,9 +648,8 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'memory_save_insight',
|
'memory_save_insight',
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Store a learned insight, decision, or knowledge extracted from the current interaction.',
|
'Store a learned insight, decision, or knowledge for the authenticated MCP actor.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
userId: z.string().describe('User ID'),
|
|
||||||
content: z.string().describe('The insight or knowledge to store'),
|
content: z.string().describe('The insight or knowledge to store'),
|
||||||
category: z
|
category: z
|
||||||
.string()
|
.string()
|
||||||
@@ -325,11 +657,13 @@ export class McpService implements OnModuleDestroy {
|
|||||||
.describe('Category: decision, learning, preference, fact, pattern, general'),
|
.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';
|
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||||
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
const embedding = this.embeddings.available ? await this.embeddings.embed(content) : null;
|
||||||
const insight = await this.memory.insights.create({
|
const insight = await this.memory.insights.create({
|
||||||
userId,
|
userId: actor.userId,
|
||||||
content,
|
content,
|
||||||
embedding,
|
embedding,
|
||||||
source: 'agent',
|
source: 'agent',
|
||||||
@@ -346,16 +680,11 @@ export class McpService implements OnModuleDestroy {
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
'Get the current orchestration mission status including milestones, tasks, and active session.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({}),
|
||||||
projectPath: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Project path. Defaults to gateway working directory.'),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
async ({ projectPath }) => {
|
async (params) => {
|
||||||
const resolvedPath = projectPath ?? process.cwd();
|
assertMcpToolAuthorized(actor, 'coord_mission_status', params);
|
||||||
const status = await this.coordService.getMissionStatus(resolvedPath);
|
const status = await this.coordService.getMissionStatus(process.cwd());
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -371,16 +700,11 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'coord_list_tasks',
|
'coord_list_tasks',
|
||||||
{
|
{
|
||||||
description: 'List all tasks from the orchestration TASKS.md file.',
|
description: 'List all tasks from the orchestration TASKS.md file.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({}),
|
||||||
projectPath: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Project path. Defaults to gateway working directory.'),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
async ({ projectPath }) => {
|
async (params) => {
|
||||||
const resolvedPath = projectPath ?? process.cwd();
|
assertMcpToolAuthorized(actor, 'coord_list_tasks', params);
|
||||||
const tasks = await this.coordService.listTasks(resolvedPath);
|
const tasks = await this.coordService.listTasks(process.cwd());
|
||||||
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
return { content: [{ type: 'text' as const, text: JSON.stringify(tasks, null, 2) }] };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -389,17 +713,14 @@ export class McpService implements OnModuleDestroy {
|
|||||||
'coord_task_detail',
|
'coord_task_detail',
|
||||||
{
|
{
|
||||||
description: 'Get detailed status for a specific orchestration task.',
|
description: 'Get detailed status for a specific orchestration task.',
|
||||||
inputSchema: z.object({
|
inputSchema: strictObject({
|
||||||
taskId: z.string().describe('Task ID (e.g. P2-005)'),
|
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 }) => {
|
async (params) => {
|
||||||
const resolvedPath = projectPath ?? process.cwd();
|
assertMcpToolAuthorized(actor, 'coord_task_detail', params);
|
||||||
const detail = await this.coordService.getTaskStatus(resolvedPath, taskId);
|
const { taskId } = params;
|
||||||
|
const detail = await this.coordService.getTaskStatus(process.cwd(), taskId);
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
89
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
89
apps/gateway/src/plugin/discord-ingress.security.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
createDiscordIngressEnvelope,
|
||||||
|
verifyDiscordIngressEnvelope,
|
||||||
|
type DiscordIngressPayload,
|
||||||
|
} from '@mosaicstack/discord-plugin';
|
||||||
|
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||||
|
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||||
|
|
||||||
|
const SERVICE_TOKEN = 'test-service-token';
|
||||||
|
|
||||||
|
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||||
|
return {
|
||||||
|
correlationId: 'correlation-001',
|
||||||
|
messageId: 'discord-message-001',
|
||||||
|
guildId: 'guild-001',
|
||||||
|
channelId: 'channel-001',
|
||||||
|
userId: 'user-001',
|
||||||
|
conversationId: 'discord-channel-001',
|
||||||
|
content: 'hello Tess',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Discord ingress security', () => {
|
||||||
|
it('accepts only the configured Discord service identity', () => {
|
||||||
|
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||||
|
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||||
|
expect(validateDiscordServiceToken(undefined, SERVICE_TOKEN)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unauthenticated or tampered service envelopes', () => {
|
||||||
|
const envelope = createDiscordIngressEnvelope(createPayload(), SERVICE_TOKEN);
|
||||||
|
|
||||||
|
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)).toEqual(createPayload());
|
||||||
|
expect(verifyDiscordIngressEnvelope(envelope, 'wrong-service-token')).toBeNull();
|
||||||
|
expect(
|
||||||
|
verifyDiscordIngressEnvelope(
|
||||||
|
{ ...envelope, payload: { ...envelope.payload, content: 'forged command' } },
|
||||||
|
SERVICE_TOKEN,
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['guild', { guildId: 'unlisted-guild' }],
|
||||||
|
['channel', { channelId: 'unlisted-channel' }],
|
||||||
|
['user', { userId: 'unlisted-user' }],
|
||||||
|
])(
|
||||||
|
'rejects an unallowlisted Discord %s',
|
||||||
|
(_kind: string, overrides: Partial<DiscordIngressPayload>) => {
|
||||||
|
const envelope = createDiscordIngressEnvelope(createPayload(overrides), SERVICE_TOKEN);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
||||||
|
guildIds: ['guild-001'],
|
||||||
|
channelIds: ['channel-001'],
|
||||||
|
userIds: ['user-001'],
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('retains Discord message and correlation IDs after authenticated allowlisted validation', () => {
|
||||||
|
const payload = createPayload({
|
||||||
|
correlationId: 'correlation-trace-123',
|
||||||
|
messageId: 'discord-snowflake-987',
|
||||||
|
});
|
||||||
|
const envelope = createDiscordIngressEnvelope(payload, SERVICE_TOKEN);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN, {
|
||||||
|
guildIds: ['guild-001'],
|
||||||
|
channelIds: ['channel-001'],
|
||||||
|
userIds: ['user-001'],
|
||||||
|
}),
|
||||||
|
).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a replayed Discord message ID while retaining bounded replay state', () => {
|
||||||
|
const replayProtector = new DiscordReplayProtector(60_000, 2);
|
||||||
|
|
||||||
|
expect(replayProtector.claim('discord-message-001')).toBe(true);
|
||||||
|
expect(replayProtector.claim('discord-message-001')).toBe(false);
|
||||||
|
expect(replayProtector.claim('discord-message-002')).toBe(true);
|
||||||
|
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
||||||
|
expect(replayProtector.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
40
apps/gateway/src/plugin/discord-replay-protector.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Bounded replay cache for Discord's globally unique native message IDs.
|
||||||
|
* Durable ingress idempotency is added with Tess's canonical inbox/outbox work.
|
||||||
|
*/
|
||||||
|
export class DiscordReplayProtector {
|
||||||
|
private readonly claimedAt = new Map<string, number>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly ttlMs = 15 * 60 * 1000,
|
||||||
|
private readonly maxEntries = 10_000,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get size(): number {
|
||||||
|
return this.claimedAt.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Claims an ID exactly once within its bounded retention window. */
|
||||||
|
claim(messageId: string, now = Date.now()): boolean {
|
||||||
|
this.prune(now);
|
||||||
|
if (this.claimedAt.has(messageId)) return false;
|
||||||
|
|
||||||
|
this.claimedAt.set(messageId, now);
|
||||||
|
this.evictOverflow();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private prune(now: number): void {
|
||||||
|
for (const [messageId, claimedAt] of this.claimedAt) {
|
||||||
|
if (now - claimedAt >= this.ttlMs) this.claimedAt.delete(messageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private evictOverflow(): void {
|
||||||
|
while (this.claimedAt.size > this.maxEntries) {
|
||||||
|
const oldestMessageId = this.claimedAt.keys().next().value;
|
||||||
|
if (oldestMessageId === undefined) return;
|
||||||
|
this.claimedAt.delete(oldestMessageId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,19 +50,41 @@ class TelegramChannelPluginAdapter implements IChannelPlugin {
|
|||||||
|
|
||||||
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
const DEFAULT_GATEWAY_URL = 'http://localhost:14242';
|
||||||
|
|
||||||
|
function requiredDiscordAllowlist(name: string): string[] {
|
||||||
|
const value = process.env[name]
|
||||||
|
?.split(',')
|
||||||
|
.map((id: string): string => id.trim())
|
||||||
|
.filter((id: string): boolean => id.length > 0);
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
throw new Error(`${name} is required when DISCORD_BOT_TOKEN is configured`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function createPluginRegistry(): IChannelPlugin[] {
|
function createPluginRegistry(): IChannelPlugin[] {
|
||||||
const plugins: IChannelPlugin[] = [];
|
const plugins: IChannelPlugin[] = [];
|
||||||
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
const discordToken = process.env['DISCORD_BOT_TOKEN'];
|
||||||
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
const discordGuildId = process.env['DISCORD_GUILD_ID'];
|
||||||
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
const discordGatewayUrl = process.env['DISCORD_GATEWAY_URL'] ?? DEFAULT_GATEWAY_URL;
|
||||||
|
const discordServiceToken = process.env['DISCORD_SERVICE_TOKEN'];
|
||||||
|
const discordServiceUserId = process.env['DISCORD_SERVICE_USER_ID'];
|
||||||
|
|
||||||
if (discordToken) {
|
if (discordToken) {
|
||||||
|
if (!discordServiceToken || !discordServiceUserId) {
|
||||||
|
throw new Error(
|
||||||
|
'DISCORD_SERVICE_TOKEN and DISCORD_SERVICE_USER_ID are required when DISCORD_BOT_TOKEN is configured',
|
||||||
|
);
|
||||||
|
}
|
||||||
plugins.push(
|
plugins.push(
|
||||||
new DiscordChannelPluginAdapter(
|
new DiscordChannelPluginAdapter(
|
||||||
new DiscordPlugin({
|
new DiscordPlugin({
|
||||||
token: discordToken,
|
token: discordToken,
|
||||||
guildId: discordGuildId,
|
guildId: discordGuildId,
|
||||||
gatewayUrl: discordGatewayUrl,
|
gatewayUrl: discordGatewayUrl,
|
||||||
|
serviceToken: discordServiceToken,
|
||||||
|
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||||
|
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||||
|
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { createQueue, type QueueHandle } from '@mosaicstack/queue';
|
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 scopedSessionId = (sessionId: string, scope: ActorTenantScope) =>
|
||||||
const SESSION_SYSTEM_FRAGMENTS_KEY = (sessionId: string) =>
|
`${scope.tenantId}:${scope.userId}:${sessionId}`;
|
||||||
`mosaic:session:${sessionId}:system:fragments`;
|
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
|
const SYSTEM_OVERRIDE_TTL_SECONDS = 604800; // 7 days
|
||||||
|
|
||||||
interface OverrideFragment {
|
interface OverrideFragment {
|
||||||
@@ -20,9 +24,9 @@ export class SystemOverrideService {
|
|||||||
this.handle = createQueue();
|
this.handle = createQueue();
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sessionId: string, override: string): Promise<void> {
|
async set(sessionId: string, override: string, scope: ActorTenantScope): Promise<void> {
|
||||||
// Load existing fragments
|
// 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
|
const fragments: OverrideFragment[] = existing
|
||||||
? (JSON.parse(existing) as OverrideFragment[])
|
? (JSON.parse(existing) as OverrideFragment[])
|
||||||
: [];
|
: [];
|
||||||
@@ -37,11 +41,11 @@ export class SystemOverrideService {
|
|||||||
// Store both: fragments array and condensed result
|
// Store both: fragments array and condensed result
|
||||||
const pipeline = this.handle.redis.pipeline();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.setex(
|
pipeline.setex(
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
SYSTEM_OVERRIDE_TTL_SECONDS,
|
SYSTEM_OVERRIDE_TTL_SECONDS,
|
||||||
JSON.stringify(fragments),
|
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();
|
await pipeline.exec();
|
||||||
|
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -49,21 +53,21 @@ export class SystemOverrideService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(sessionId: string): Promise<string | null> {
|
async get(sessionId: string, scope: ActorTenantScope): Promise<string | null> {
|
||||||
return this.handle.redis.get(SESSION_SYSTEM_KEY(sessionId));
|
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();
|
const pipeline = this.handle.redis.pipeline();
|
||||||
pipeline.expire(SESSION_SYSTEM_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId), SYSTEM_OVERRIDE_TTL_SECONDS);
|
pipeline.expire(SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope), SYSTEM_OVERRIDE_TTL_SECONDS);
|
||||||
await pipeline.exec();
|
await pipeline.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
async clear(sessionId: string): Promise<void> {
|
async clear(sessionId: string, scope: ActorTenantScope): Promise<void> {
|
||||||
await this.handle.redis.del(
|
await this.handle.redis.del(
|
||||||
SESSION_SYSTEM_KEY(sessionId),
|
SESSION_SYSTEM_KEY(sessionId, scope),
|
||||||
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId),
|
SESSION_SYSTEM_FRAGMENTS_KEY(sessionId, scope),
|
||||||
);
|
);
|
||||||
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
this.logger.debug(`Cleared system override for session ${sessionId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,10 +67,11 @@ The MVP is complete when ALL declared workstreams are complete AND every cross-c
|
|||||||
|
|
||||||
## Workstreams
|
## Workstreams
|
||||||
|
|
||||||
| # | ID | Name | Status | Manifest | Notes |
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
| --- | ---- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
| 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)
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
|
|
||||||
|
|||||||
96
docs/PRD.md
96
docs/PRD.md
@@ -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
|
## Architecture
|
||||||
|
|
||||||
### High-Level System Diagram
|
### High-Level System Diagram
|
||||||
|
|||||||
@@ -14,9 +14,10 @@
|
|||||||
|
|
||||||
## Workstream Rollup
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | workstream | progress | tasks file | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
| --- | ----------------- | ---------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
|
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
|
||||||
|
|
||||||
## Cross-Cutting Tracking
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,10 @@ The following sections document how each supported channel maps its native messa
|
|||||||
|
|
||||||
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
**Outbound:** Adapter calls Discord REST `POST /channels/{id}/messages`. Markdown content is sent as-is (Discord renders it). For `contentType = "code"` the adapter wraps in triple-backtick fences with the `metadata.language` tag.
|
||||||
|
|
||||||
|
### Discord service ingress security
|
||||||
|
|
||||||
|
The Discord adapter is an authenticated gateway service, not an anonymous Socket.IO client. It presents `DISCORD_SERVICE_TOKEN` during its `/chat` connection and signs each inbound envelope using HMAC-SHA-256. The envelope contains the Discord native message ID and a generated correlation ID. Gateway verifies the service credential, signature, and configured guild/channel/user allowlists before agent dispatch, then rejects duplicate native message IDs inside its bounded replay window. All three allowlists are default-deny and required when the Discord plugin is enabled. The service credential is injected at runtime and is never logged or included in protocol payloads.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Telegram
|
### Telegram
|
||||||
|
|||||||
@@ -293,13 +293,24 @@ Each OIDC provider requires its client ID, client secret, and issuer URL togethe
|
|||||||
|
|
||||||
### Plugins
|
### Plugins
|
||||||
|
|
||||||
| Variable | Description |
|
| Variable | Description |
|
||||||
| ---------------------- | -------------------------------------------------------------------------- |
|
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
| `DISCORD_BOT_TOKEN` | Discord bot token (enables Discord plugin) |
|
||||||
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
| `DISCORD_SERVICE_TOKEN` | Required high-entropy service credential used to authenticate and sign Discord ingress; inject through the approved secret mechanism only |
|
||||||
| `DISCORD_GATEWAY_URL` | Gateway URL for Discord plugin to call (default: `http://localhost:14242`) |
|
| `DISCORD_SERVICE_USER_ID` | Required Mosaic service-principal user ID that owns persisted Discord conversations; the original Discord user ID remains audit metadata |
|
||||||
| `TELEGRAM_BOT_TOKEN` | Telegram bot token (enables Telegram plugin) |
|
| `DISCORD_GUILD_ID` | Discord guild/server ID |
|
||||||
| `TELEGRAM_GATEWAY_URL` | Gateway URL for Telegram plugin to call |
|
| `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
|
### Observability
|
||||||
|
|
||||||
|
|||||||
33
docs/scratchpads/561-python-is-python3.md
Normal file
33
docs/scratchpads/561-python-is-python3.md
Normal 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.
|
||||||
49
docs/scratchpads/703-wrapper-interactive-auth.md
Normal file
49
docs/scratchpads/703-wrapper-interactive-auth.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# #703 Git Wrapper Interactive and Auth Resilience
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Restore the deployed Git wrapper contract: issue-create supports interactive invocation and Gitea mutation behavior tolerates a stale Tea authenticated user by validating current identity and using the existing host-scoped API fallback.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- `packages/mosaic/framework/tools/git/issue-create.sh`
|
||||||
|
- `packages/mosaic/framework/tools/git/detect-platform.sh`
|
||||||
|
- Git wrapper regression harnesses
|
||||||
|
- This scratchpad
|
||||||
|
|
||||||
|
## Requirements / acceptance evidence
|
||||||
|
|
||||||
|
1. `issue-create -i` and `--interactive` prompt for missing issue fields without exposing credentials.
|
||||||
|
2. Explicit command-line fields retain precedence and do not trigger prompt input.
|
||||||
|
3. Gitea wrapper resolves the current user dynamically from the target host and does not rely on the saved Tea user identity.
|
||||||
|
4. A Tea `GetUserByName` failure falls back to authenticated API creation.
|
||||||
|
5. Existing body-safety, login-resolution, issue-create, and pr-create paths remain green.
|
||||||
|
6. Source framework is re-seeded to deployed `~/.config/mosaic`, then deployed wrappers are verified end to end.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing shell regression harness for interactive input and stale Tea user fallback.
|
||||||
|
2. Implement minimal helper and parser changes.
|
||||||
|
3. Run wrapper harnesses, syntax checks, and repository baseline checks.
|
||||||
|
4. Re-seed deployed framework and run live wrapper verification.
|
||||||
|
5. Commit, queue guard, push, open PR, and stop for independent review.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- Issue #703 filed before code; issue comment records #536 root cause and stale-login trigger.
|
||||||
|
- Deployed wrapper `issue-create.sh -i` reproduced: `Unknown option: -i` (exit 1).
|
||||||
|
- Live Tea mutation did not reproduce `GetUserByName` on this host because the current mosaicstack Tea login is valid. The test harness models the reported stale authenticated-user condition.
|
||||||
|
- Implemented `-i` / `--interactive` prompt collection and a dynamic Tea `/user` validation. A stale Tea identity now selects the existing host-scoped Gitea API fallback before mutation for both issue and PR creation.
|
||||||
|
- Re-seeded the framework with `MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 bash packages/mosaic/framework/install.sh`. Installed and source wrapper SHA-256 values matched.
|
||||||
|
- Live deployed verification: interactive issue-create opened then closed #704; installed dynamic identity resolved `jason.woltje`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-issue-create-body-safety.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-gitea-login-resolution.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-pr-metadata-gitea.sh`
|
||||||
|
- PASS: `packages/mosaic/framework/tools/git/test-pr-merge-gitea-empty-uid.sh`
|
||||||
|
- PASS: `bash packages/mosaic/framework/tools/git/test-lane-brief-pr-linkage.sh`
|
||||||
|
- PASS: `bash -n packages/mosaic/framework/tools/git/*.sh`
|
||||||
|
- PASS: Prettier check for this scratchpad
|
||||||
52
docs/scratchpads/FED-M3-05-list-verb.md
Normal file
52
docs/scratchpads/FED-M3-05-list-verb.md
Normal 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.
|
||||||
58
docs/scratchpads/tess-20260712.md
Normal file
58
docs/scratchpads/tess-20260712.md
Normal 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.
|
||||||
38
docs/scratchpads/tess-m1-002-provider-registry.md
Normal file
38
docs/scratchpads/tess-m1-002-provider-registry.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# TESS-M1-002 — Provider Registry
|
||||||
|
|
||||||
|
- **Issue:** #707
|
||||||
|
- **Branch:** `feat/tess-provider-registry`
|
||||||
|
- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract.
|
||||||
|
2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure.
|
||||||
|
3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits.
|
||||||
|
4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates.
|
||||||
|
|
||||||
|
## Security Invariants
|
||||||
|
|
||||||
|
- Caller-supplied actor/tenant identity never reaches runtime providers.
|
||||||
|
- Provider capability absence and approval/audit failure deny before side effects.
|
||||||
|
- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context.
|
||||||
|
- Audit events retain correlation and authority metadata but never message content or approval material.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed.
|
||||||
|
- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation.
|
||||||
|
- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary.
|
||||||
|
- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable.
|
||||||
|
- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected.
|
||||||
|
- Focused: package registry 2 tests and gateway security boundary 7 tests pass.
|
||||||
|
- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed.
|
||||||
|
- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped).
|
||||||
|
|
||||||
|
## Risks / Blockers
|
||||||
|
|
||||||
|
- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it.
|
||||||
27
docs/scratchpads/tess-m1-sec-001.md
Normal file
27
docs/scratchpads/tess-m1-sec-001.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# TESS-M1-SEC-001 — Command authorization and exact-action approval
|
||||||
|
|
||||||
|
- Issue/milestone: #707 / M1
|
||||||
|
- Branch: `fix/tess-command-authz`
|
||||||
|
- Requirement: `TESS-SEC-002`, with approval binding controls from `TESS-SEC-007`
|
||||||
|
- Scope: `apps/gateway` only, plus required in-repo security/developer documentation.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Locate the gateway command executor, command metadata, authorization context, and existing test conventions.
|
||||||
|
2. Write abuse/authz tests before production changes. Expected red cases: non-admin blocked from admin/system command; forged caller scope cannot authorize; privileged/destructive action requires durable exact-action approval; expired/replayed/mutated approvals deny.
|
||||||
|
3. Implement server-derived role/scope enforcement and durable approval validation/consumption with audit results.
|
||||||
|
4. Run focused security tests, then repository baseline gates: typecheck, lint, format-check, test.
|
||||||
|
5. Run independent security/code review, commit, queue-guard, push, and open the PR to `main` through the stated Gitea API fallback. Stop after PR creation.
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- The existing gateway persistence interface is the available durable approval boundary. If no persistence abstraction exists, a minimal injectable repository interface will be introduced rather than an in-memory approval implementation, because TESS-SEC-002/007 require durable enforcement.
|
||||||
|
- “Exact action” is a canonical digest over structured command identity and normalized arguments; role/scope checks always use authenticated server context, not client-declared claims.
|
||||||
|
|
||||||
|
## TDD evidence
|
||||||
|
|
||||||
|
- Pending: abuse/authz test written and observed red before implementation.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- Pending.
|
||||||
35
docs/scratchpads/tess-m1-sec-004-discord-ingress.md
Normal file
35
docs/scratchpads/tess-m1-sec-004-discord-ingress.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Scratchpad — TESS-M1-SEC-004 Discord ingress
|
||||||
|
|
||||||
|
- **Task / issue:** TESS-M1-SEC-004 / #707
|
||||||
|
- **Branch:** `fix/tess-discord-ingress` from `origin/main` at `59e49cfd`
|
||||||
|
- **Objective:** Authenticate the Discord plugin service at gateway ingress; enforce explicit guild/channel/user allowlists; attach Discord message and generated correlation IDs; reject replayed native message IDs.
|
||||||
|
- **Scope:** `plugins/discord`, `apps/gateway`, and existing Discord admin/developer protocol docs.
|
||||||
|
- **Budget:** Task estimate 28K; no explicit hard cap supplied.
|
||||||
|
- **Assumptions:** The Discord plugin and gateway share an injected high-entropy `DISCORD_SERVICE_TOKEN`; a configured Discord plugin fails closed without it. Allowlist configuration is comma-separated Discord snowflakes. Discord native message ID is the replay key, with bounded in-memory retention pending the M2 durable inbox/idempotency work.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing tests covering ingress service authentication/signing, unlisted guild/channel/user rejection, correlation propagation, and replay rejection.
|
||||||
|
2. Implement the signed Discord ingress envelope and allowlist validation in the plugin.
|
||||||
|
3. Authenticate and validate the envelope at the gateway boundary, then enforce bounded replay protection before agent dispatch.
|
||||||
|
4. Document the service-token and allowlist operations; run focused and baseline gates; obtain independent review.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-12: Intake complete; PRD TESS-SEC-005, architecture, and threat model reviewed.
|
||||||
|
- Added service-token Socket.IO authentication, HMAC-signed Discord envelopes, default-deny guild/channel/user allowlists, correlated message metadata, bounded replay rejection, and fail-fast configuration checks.
|
||||||
|
- Code and security reviews completed. Code review findings on service persistence ownership, package-boundary tests, disconnected ingress observability, and chat payload validation were remediated; final independent code review approved.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- Existing `main` has known unrelated Prettier debt; only changed files will be held format-clean. Durable replay persistence is intentionally out of scope for this M1 prerequisite and belongs to TESS-M2 durable inbox/idempotency work.
|
||||||
|
|
||||||
|
## Verification evidence
|
||||||
|
|
||||||
|
- Focused ingress suite: `pnpm --filter @mosaicstack/gateway test -- discord-ingress.security.spec.ts` — 7 passed.
|
||||||
|
- Gateway suite: `pnpm --filter @mosaicstack/gateway test` — 513 passed, 11 skipped.
|
||||||
|
- Plugin suite: `pnpm --filter @mosaicstack/discord-plugin test` — no tests, passed by configured `--passWithNoTests`.
|
||||||
|
- `pnpm typecheck` — passed.
|
||||||
|
- `pnpm lint` — passed.
|
||||||
|
- `pnpm format:check` — fails only on the known pre-existing Tess documentation debt listed in the task dispatch; changed files pass targeted Prettier verification.
|
||||||
|
- Codex security review — no findings; final Codex code review — approved.
|
||||||
77
docs/tess/ARCHITECTURE.md
Normal file
77
docs/tess/ARCHITECTURE.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### M1 Registry Boundary
|
||||||
|
|
||||||
|
`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references.
|
||||||
|
|
||||||
|
Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
|
||||||
|
|
||||||
|
## 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.
|
||||||
34
docs/tess/MIGRATION-INVENTORY.md
Normal file
34
docs/tess/MIGRATION-INVENTORY.md
Normal 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.
|
||||||
46
docs/tess/MISSION-MANIFEST.md
Normal file
46
docs/tess/MISSION-MANIFEST.md
Normal 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
34
docs/tess/TASKS.md
Normal 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
46
docs/tess/THREAT-MODEL.md
Normal 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.
|
||||||
30
docs/tess/VERIFICATION-MATRIX.md
Normal file
30
docs/tess/VERIFICATION-MATRIX.md
Normal 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.
|
||||||
@@ -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
|
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
|
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export const VERSION = '0.0.0';
|
export const VERSION = '0.0.0';
|
||||||
|
|
||||||
|
export * from './runtime-provider-registry.js';
|
||||||
|
|||||||
95
packages/agent/src/runtime-provider-registry.test.ts
Normal file
95
packages/agent/src/runtime-provider-registry.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js';
|
||||||
|
|
||||||
|
class TestRuntimeProvider implements AgentRuntimeProvider {
|
||||||
|
readonly id = 'test-runtime';
|
||||||
|
|
||||||
|
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||||
|
return { supported: ['session.list'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||||
|
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(_scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(_scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamSession(
|
||||||
|
_sessionId: string,
|
||||||
|
_cursor: string | undefined,
|
||||||
|
_scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
_sessionId: string,
|
||||||
|
_message: RuntimeMessage,
|
||||||
|
_scope: RuntimeScope,
|
||||||
|
): Promise<void> {}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
_sessionId: string,
|
||||||
|
_mode: RuntimeAttachMode,
|
||||||
|
_scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return {
|
||||||
|
attachmentId: 'attachment-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
mode: 'read',
|
||||||
|
expiresAt: '2026-07-12T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(_attachmentId: string, _scope: RuntimeScope): Promise<void> {}
|
||||||
|
|
||||||
|
async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentRuntimeProviderRegistry', (): void => {
|
||||||
|
it('resolves only registered runtime providers', (): void => {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
const provider = new TestRuntimeProvider();
|
||||||
|
|
||||||
|
registry.register(provider);
|
||||||
|
|
||||||
|
expect(registry.get(provider.id)).toBe(provider);
|
||||||
|
expect(registry.get('unknown-runtime')).toBeUndefined();
|
||||||
|
expect(registry.list()).toEqual([provider]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
const provider = new TestRuntimeProvider();
|
||||||
|
|
||||||
|
registry.register(provider);
|
||||||
|
|
||||||
|
expect((): void => {
|
||||||
|
registry.register(provider);
|
||||||
|
}).toThrow(/already registered/);
|
||||||
|
expect((): void => {
|
||||||
|
registry.require('');
|
||||||
|
}).toThrow(/provider ID is required/);
|
||||||
|
expect((): void => {
|
||||||
|
registry.require('unknown-runtime');
|
||||||
|
}).toThrow(/not registered/);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/agent/src/runtime-provider-registry.ts
Normal file
40
packages/agent/src/runtime-provider-registry.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { AgentRuntimeProvider } from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry for runtime providers. Registration is explicit and replacement is
|
||||||
|
* forbidden so a provider identity cannot be silently hijacked at runtime.
|
||||||
|
*/
|
||||||
|
export class AgentRuntimeProviderRegistry {
|
||||||
|
private readonly providers = new Map<string, AgentRuntimeProvider>();
|
||||||
|
|
||||||
|
register(provider: AgentRuntimeProvider): void {
|
||||||
|
const providerId = provider.id.trim();
|
||||||
|
if (providerId.length === 0) {
|
||||||
|
throw new Error('Runtime provider ID is required');
|
||||||
|
}
|
||||||
|
if (this.providers.has(providerId)) {
|
||||||
|
throw new Error(`Runtime provider is already registered: ${providerId}`);
|
||||||
|
}
|
||||||
|
this.providers.set(providerId, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(providerId: string): AgentRuntimeProvider | undefined {
|
||||||
|
return this.providers.get(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
require(providerId: string): AgentRuntimeProvider {
|
||||||
|
const normalizedProviderId = providerId.trim();
|
||||||
|
if (normalizedProviderId.length === 0) {
|
||||||
|
throw new Error('Runtime provider ID is required');
|
||||||
|
}
|
||||||
|
const provider = this.providers.get(normalizedProviderId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`);
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): AgentRuntimeProvider[] {
|
||||||
|
return Array.from(this.providers.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,3 +10,8 @@ export {
|
|||||||
type LogQuery,
|
type LogQuery,
|
||||||
} from './agent-logs.js';
|
} from './agent-logs.js';
|
||||||
export { registerLogCommand } from './cli.js';
|
export { registerLogCommand } from './cli.js';
|
||||||
|
export {
|
||||||
|
redactSensitiveContent,
|
||||||
|
type RedactionResult,
|
||||||
|
type SensitiveClassification,
|
||||||
|
} from './redaction.js';
|
||||||
|
|||||||
25
packages/log/src/redaction.spec.ts
Normal file
25
packages/log/src/redaction.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { redactSensitiveContent } from './redaction.js';
|
||||||
|
|
||||||
|
describe('redactSensitiveContent', (): void => {
|
||||||
|
it('redacts seeded secret and PII canaries before persistence or egress', (): void => {
|
||||||
|
const result = redactSensitiveContent(
|
||||||
|
'email canary@example.test token=sk_CANARY12345678 phone +1 555 555 1212',
|
||||||
|
);
|
||||||
|
expect(result.content).not.toContain('canary@example.test');
|
||||||
|
expect(result.content).not.toContain('sk_CANARY12345678');
|
||||||
|
expect(result.content).not.toContain('+1 555 555 1212');
|
||||||
|
expect(result.classifications).toEqual(['secret', 'pii']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts common provider credential formats', (): void => {
|
||||||
|
const result = redactSensitiveContent(
|
||||||
|
'Authorization: Bearer canary.bearer.token jwt eyJcanary.eyJpayload.eyJsignature aws AKIACANARY1234567890',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.content).not.toContain('canary.bearer.token');
|
||||||
|
expect(result.content).not.toContain('eyJcanary.eyJpayload.eyJsignature');
|
||||||
|
expect(result.content).not.toContain('AKIACANARY1234567890');
|
||||||
|
expect(result.classifications).toEqual(['secret']);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/log/src/redaction.ts
Normal file
40
packages/log/src/redaction.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
export type SensitiveClassification = 'secret' | 'pii';
|
||||||
|
|
||||||
|
export interface RedactionResult {
|
||||||
|
content: string;
|
||||||
|
classifications: SensitiveClassification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SECRET_PATTERNS: RegExp[] = [
|
||||||
|
/\b(?:sk|ghp|gitea)_[A-Za-z0-9_-]{8,}\b/g,
|
||||||
|
/\b(?:api[_-]?key|token|password|secret)\s*[:=]\s*[^\s,;]+/gi,
|
||||||
|
/\b(?:authorization\s*:\s*)?bearer\s+[A-Za-z0-9._~+/-]+=*/gi,
|
||||||
|
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g,
|
||||||
|
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
||||||
|
/-----BEGIN(?: [A-Z]+)* KEY-----[\s\S]*?-----END(?: [A-Z]+)* KEY-----/g,
|
||||||
|
/https?:\/\/[^\s?#]+[^\s]*[?&](?:token|key|secret|signature|sig)=[^\s&#]+/gi,
|
||||||
|
];
|
||||||
|
const PII_PATTERNS: RegExp[] = [
|
||||||
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
|
||||||
|
/\b\+?\d[\d(). -]{7,}\d\b/g,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function redactSensitiveContent(content: string): RedactionResult {
|
||||||
|
let redacted = content;
|
||||||
|
const classifications: SensitiveClassification[] = [];
|
||||||
|
for (const pattern of SECRET_PATTERNS) {
|
||||||
|
if (pattern.test(redacted)) {
|
||||||
|
classifications.push('secret');
|
||||||
|
redacted = redacted.replace(pattern, '[REDACTED_SECRET]');
|
||||||
|
}
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
for (const pattern of PII_PATTERNS) {
|
||||||
|
if (pattern.test(redacted)) {
|
||||||
|
classifications.push('pii');
|
||||||
|
redacted = redacted.replace(pattern, '[REDACTED_PII]');
|
||||||
|
}
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
}
|
||||||
|
return { content: redacted, classifications: [...new Set(classifications)] };
|
||||||
|
}
|
||||||
@@ -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
|
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
|
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -15,13 +15,23 @@
|
|||||||
#
|
#
|
||||||
# After loading, service-specific env vars are exported.
|
# After loading, service-specific env vars are exported.
|
||||||
# Run `load_credentials --help` for details.
|
# 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
|
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
|
if [[ -f "$_cand" ]]; then MOSAIC_CREDENTIALS_FILE="$_cand"; break; fi
|
||||||
done
|
done
|
||||||
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
: "${MOSAIC_CREDENTIALS_FILE:=$HOME/.config/mosaic/credentials.json}"
|
||||||
fi
|
fi
|
||||||
|
export MOSAIC_CREDENTIALS_FILE
|
||||||
|
|
||||||
_mosaic_require_jq() {
|
_mosaic_require_jq() {
|
||||||
if ! command -v jq &>/dev/null; then
|
if ! command -v jq &>/dev/null; then
|
||||||
|
|||||||
@@ -86,7 +86,16 @@ gitea_url_matches_host() {
|
|||||||
|
|
||||||
get_gitea_service_for_host() {
|
get_gitea_service_for_host() {
|
||||||
local host="$1"
|
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
|
case "$host" in
|
||||||
git.mosaicstack.dev)
|
git.mosaicstack.dev)
|
||||||
@@ -231,6 +240,33 @@ get_gitea_login_for_host() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Validate the current authenticated Gitea user for a resolved Tea login.
|
||||||
|
# Tea stores a user name with each login which can become stale after user rename,
|
||||||
|
# token rotation, or server migration. Querying /user derives the identity from the
|
||||||
|
# active credential instead of trusting that saved name. Callers fall back to the
|
||||||
|
# host-scoped API path when this validation fails.
|
||||||
|
get_gitea_authenticated_user() {
|
||||||
|
local login_name="$1" response
|
||||||
|
|
||||||
|
command -v tea >/dev/null 2>&1 || return 1
|
||||||
|
response=$(tea api --login "$login_name" /user 2>/dev/null) || return 1
|
||||||
|
TEA_AUTHENTICATED_USER_JSON="$response" python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = json.loads(os.environ["TEA_AUTHENTICATED_USER_JSON"])
|
||||||
|
except (KeyError, json.JSONDecodeError):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
login = user.get("login") if isinstance(user, dict) else None
|
||||||
|
if isinstance(login, str) and login:
|
||||||
|
print(login)
|
||||||
|
raise SystemExit(0)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
get_default_tea_login() {
|
get_default_tea_login() {
|
||||||
local logins_json
|
local logins_json
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Examples:
|
|||||||
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
$(basename "$0") -i 42 -l "in-progress" -m "0.2.0"
|
||||||
$(basename "$0") -i 42 -a @me
|
$(basename "$0") -i 42 -a @me
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ TITLE=""
|
|||||||
BODY=""
|
BODY=""
|
||||||
LABELS=""
|
LABELS=""
|
||||||
MILESTONE=""
|
MILESTONE=""
|
||||||
|
INTERACTIVE=false
|
||||||
|
|
||||||
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
# get_remote_host and get_gitea_token are provided by detect-platform.sh
|
||||||
|
|
||||||
@@ -66,13 +67,15 @@ Options:
|
|||||||
-b, --body BODY Issue body/description
|
-b, --body BODY Issue body/description
|
||||||
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
-l, --labels LABELS Comma-separated labels (e.g., "bug,feature")
|
||||||
-m, --milestone NAME Milestone name to assign
|
-m, --milestone NAME Milestone name to assign
|
||||||
|
-i, --interactive Prompt for missing issue fields
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
$(basename "$0") -t "Fix login bug" -l "bug,priority-high"
|
||||||
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
$(basename "$0") -t "Add dark mode" -b "Implement theme switching" -m "0.2.0"
|
||||||
|
$(basename "$0") -i
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -94,8 +97,12 @@ while [[ $# -gt 0 ]]; do
|
|||||||
MILESTONE="$2"
|
MILESTONE="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
-i|--interactive)
|
||||||
|
INTERACTIVE=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
@@ -104,6 +111,13 @@ while [[ $# -gt 0 ]]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
if [[ "$INTERACTIVE" == true ]]; then
|
||||||
|
[[ -n "$TITLE" ]] || read -r -p "Issue title: " TITLE
|
||||||
|
[[ -n "$BODY" ]] || read -r -p "Issue body (optional): " BODY || true
|
||||||
|
[[ -n "$LABELS" ]] || read -r -p "Labels, comma-separated (optional): " LABELS || true
|
||||||
|
[[ -n "$MILESTONE" ]] || read -r -p "Milestone (optional): " MILESTONE || true
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ -z "$TITLE" ]]; then
|
if [[ -z "$TITLE" ]]; then
|
||||||
echo "Error: Title is required (-t)" >&2
|
echo "Error: Title is required (-t)" >&2
|
||||||
usage
|
usage
|
||||||
@@ -127,6 +141,11 @@ case "$PLATFORM" in
|
|||||||
gitea_issue_create_api
|
gitea_issue_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
|
gitea_issue_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea issue create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ Examples:
|
|||||||
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
$(basename "$0") -m "0.2.0" # Issues in milestone 0.2.0
|
||||||
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
$(basename "$0") --repo ddk/ai-bma # List issues from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -67,7 +67,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ Examples:
|
|||||||
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
$(basename "$0") -t "0.0.1" -d "Pre-MVP Foundation Sprint"
|
||||||
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
$(basename "$0") -t "0.1.0" -d "MVP Release" --due "2025-03-01"
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ Examples:
|
|||||||
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
$(basename "$0") -i 42 -b "Implements the feature described in #42"
|
||||||
$(basename "$0") -t "WIP: New feature" --draft
|
$(basename "$0") -t "WIP: New feature" --draft
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -125,7 +125,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
@@ -183,6 +183,11 @@ case "$PLATFORM" in
|
|||||||
gitea_pr_create_api
|
gitea_pr_create_api
|
||||||
exit $?
|
exit $?
|
||||||
}
|
}
|
||||||
|
if ! get_gitea_authenticated_user "$GITEA_LOGIN_NAME" >/dev/null; then
|
||||||
|
echo "Warning: Tea authenticated-user validation failed (possible stale user/login); trying Gitea API fallback..." >&2
|
||||||
|
gitea_pr_create_api
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
REPO_ARGS=(--repo "$REPO_SLUG" --login "$GITEA_LOGIN_NAME")
|
||||||
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
CMD=(tea pr create "${REPO_ARGS[@]}" --title "$TITLE")
|
||||||
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
[[ -n "$BODY" ]] && CMD+=(--description "$BODY")
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Examples:
|
|||||||
$(basename "$0") -s merged -a username # Merged PRs by user
|
$(basename "$0") -s merged -a username # Merged PRs by user
|
||||||
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
$(basename "$0") --repo ddk/ai-bma # List PRs from anywhere
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -61,7 +61,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Examples:
|
|||||||
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
$(basename "$0") -n 42 -d # Squash merge and delete branch
|
||||||
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
$(basename "$0") -n 42 --skip-queue-guard # Skip queue guard wait
|
||||||
EOF
|
EOF
|
||||||
exit 1
|
exit "${1:-1}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse arguments
|
# Parse arguments
|
||||||
@@ -63,7 +63,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
-h|--help)
|
-h|--help)
|
||||||
usage
|
usage 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unknown option: $1" >&2
|
echo "Unknown option: $1" >&2
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ JSON
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "api" ]]; then
|
||||||
|
printf '%s\n' '{"login":"ci-bot"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
if [[ "${MOSAIC_TEA_FAIL_PR_CREATE:-}" == "1" && "$*" == pr\ create* ]]; then
|
||||||
echo 'GetUserByName: simulated stale login failure' >&2
|
echo 'GetUserByName: simulated stale login failure' >&2
|
||||||
|
|||||||
53
packages/mosaic/framework/tools/git/test-help-exit-code.sh
Executable file
53
packages/mosaic/framework/tools/git/test-help-exit-code.sh
Executable 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"
|
||||||
@@ -55,6 +55,11 @@ JSON
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "api" ]]; then
|
||||||
|
printf '%s\n' '{"login":"ci-bot"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
if [[ "${1:-}" == "issue" && "${2:-}" == "create" ]]; then
|
||||||
desc=""
|
desc=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
|||||||
88
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh
Executable file
88
packages/mosaic/framework/tools/git/test-issue-create-interactive-auth.sh
Executable file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regression harness for #703: interactive issue creation and stale Tea-user fallback.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
WORK_DIR="${MOSAIC_TEST_WORK_DIR:-$PWD/.mosaic-test-work/issue-create-interactive-auth}"
|
||||||
|
REPO_DIR="$WORK_DIR/repo"
|
||||||
|
BIN_DIR="$WORK_DIR/bin"
|
||||||
|
LOG_FILE="$WORK_DIR/calls.log"
|
||||||
|
CREDENTIALS_FILE="$WORK_DIR/credentials.json"
|
||||||
|
|
||||||
|
rm -rf "$WORK_DIR"
|
||||||
|
mkdir -p "$REPO_DIR" "$BIN_DIR"
|
||||||
|
git -C "$REPO_DIR" init -q
|
||||||
|
git -C "$REPO_DIR" remote add origin https://git.mosaicstack.dev/mosaicstack/stack.git
|
||||||
|
|
||||||
|
cat > "$CREDENTIALS_FILE" <<'JSON'
|
||||||
|
{"gitea":{"mosaicstack":{"url":"https://git.mosaicstack.dev","token":"test-token"}}}
|
||||||
|
JSON
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/tea" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
if [[ "$*" == "login list --output json" ]]; then
|
||||||
|
printf '%s\n' '[{"name":"mosaicstack","url":"https://git.mosaicstack.dev"}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" == "api" ]]; then
|
||||||
|
if [[ "${MOSAIC_TEA_STALE_USER:-0}" == "1" ]]; then
|
||||||
|
echo 'GetUserByName: stale configured user' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf '%s\n' '{"login":"current-user"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'tea %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
exit 0
|
||||||
|
SH
|
||||||
|
|
||||||
|
cat > "$BIN_DIR/curl" <<'SH'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
printf 'curl %s\n' "$*" >> "$MOSAIC_TEST_LOG"
|
||||||
|
printf '%s\n' '{"number":703}'
|
||||||
|
SH
|
||||||
|
chmod +x "$BIN_DIR/tea" "$BIN_DIR/curl"
|
||||||
|
|
||||||
|
run_wrapper() {
|
||||||
|
(
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
PATH="$BIN_DIR:$PATH" \
|
||||||
|
MOSAIC_CREDENTIALS_FILE="$CREDENTIALS_FILE" \
|
||||||
|
MOSAIC_TEST_LOG="$LOG_FILE" \
|
||||||
|
"$@"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
printf 'Interactive title\nInteractive body\nlabel-a,label-b\nM1\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i >/dev/null
|
||||||
|
|
||||||
|
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Interactive title --description Interactive body --labels label-a,label-b --milestone M1' "$LOG_FILE"
|
||||||
|
|
||||||
|
# Explicit values take precedence in interactive mode: no title input is
|
||||||
|
# supplied, but the wrapper still creates the issue with the explicit title.
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
printf '\n\n\n' | run_wrapper "$SCRIPT_DIR/issue-create.sh" -i -t 'Explicit title' >/dev/null
|
||||||
|
grep -q -- 'tea issue create --repo mosaicstack/stack --login mosaicstack --title Explicit title' "$LOG_FILE"
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/issue-create.sh" -t 'Fallback title' -b 'Fallback body' >/dev/null 2>"$WORK_DIR/issue-stderr"
|
||||||
|
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/issues' "$LOG_FILE"
|
||||||
|
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/issue-stderr"
|
||||||
|
if grep -q -- 'tea issue create' "$LOG_FILE"; then
|
||||||
|
echo 'FAIL: issue-create invoked Tea mutation after stale-user validation failed' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > "$LOG_FILE"
|
||||||
|
run_wrapper env MOSAIC_TEA_STALE_USER=1 "$SCRIPT_DIR/pr-create.sh" -t 'PR fallback' -H feature/wrapfix >/dev/null 2>"$WORK_DIR/pr-stderr"
|
||||||
|
grep -q -- 'curl .*https://git.mosaicstack.dev/api/v1/repos/mosaicstack/stack/pulls' "$LOG_FILE"
|
||||||
|
grep -q -- 'Tea authenticated-user validation failed' "$WORK_DIR/pr-stderr"
|
||||||
|
if grep -q -- 'tea pr create' "$LOG_FILE"; then
|
||||||
|
echo 'FAIL: pr-create invoked Tea mutation after stale-user validation failed' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo 'issue-create interactive/auth regression harness passed'
|
||||||
@@ -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).
|
- `agent-send.sh` — inter-agent wrapper (preamble + local/remote dispatch).
|
||||||
- `send-message.sh` — low-level reliable single-pane submitter (`-b` base64 input).
|
- `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.
|
- `test-send-message-socket.sh` — smoke test for named-socket isolation.
|
||||||
|
|
||||||
## Distribution
|
## Distribution
|
||||||
|
|||||||
80
packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh
Executable file
80
packages/mosaic/framework/tools/tmux/auto-submit-drafts.sh
Executable 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
|
||||||
@@ -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
|
# 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`.
|
# 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.
|
# -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 -p -b "$BUF" -t "$EFFECTIVE_TARGET" 2>/dev/null \
|
||||||
|| "${tmux_cmd[@]}" paste-buffer -d -b __mosaic_send -t "$EFFECTIVE_TARGET"
|
|| "${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
|
sleep 0.5
|
||||||
|
|
||||||
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
# 2) Submit, then verify; flush with another Enter if it is still a draft.
|
||||||
|
|||||||
@@ -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"
|
fail "agent-send.sh leaked named-socket message to default tmux server"
|
||||||
fi
|
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"
|
echo "ok - named tmux socket send tools"
|
||||||
|
|||||||
44
packages/types/src/agent/agent-runtime-provider.spec.ts
Normal file
44
packages/types/src/agent/agent-runtime-provider.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
110
packages/types/src/agent/agent-runtime-provider.ts
Normal file
110
packages/types/src/agent/agent-runtime-provider.ts
Normal 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>;
|
||||||
|
}
|
||||||
@@ -2,3 +2,5 @@
|
|||||||
export interface AgentSessionHandle {
|
export interface AgentSessionHandle {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export * from './agent-runtime-provider.js';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
CommandManifestPayload,
|
CommandManifestPayload,
|
||||||
|
SlashCommandApprovalResultPayload,
|
||||||
SlashCommandPayload,
|
SlashCommandPayload,
|
||||||
SlashCommandResultPayload,
|
SlashCommandResultPayload,
|
||||||
SystemReloadPayload,
|
SystemReloadPayload,
|
||||||
@@ -116,6 +117,7 @@ export interface ServerToClientEvents {
|
|||||||
'session:info': (payload: SessionInfoPayload) => void;
|
'session:info': (payload: SessionInfoPayload) => void;
|
||||||
'commands:manifest': (payload: CommandManifestPayload) => void;
|
'commands:manifest': (payload: CommandManifestPayload) => void;
|
||||||
'command:result': (payload: SlashCommandResultPayload) => void;
|
'command:result': (payload: SlashCommandResultPayload) => void;
|
||||||
|
'command:approval': (payload: SlashCommandApprovalResultPayload) => void;
|
||||||
'system:reload': (payload: SystemReloadPayload) => void;
|
'system:reload': (payload: SystemReloadPayload) => void;
|
||||||
error: (payload: ErrorPayload) => void;
|
error: (payload: ErrorPayload) => void;
|
||||||
}
|
}
|
||||||
@@ -125,5 +127,6 @@ export interface ClientToServerEvents {
|
|||||||
message: (data: ChatMessagePayload) => void;
|
message: (data: ChatMessagePayload) => void;
|
||||||
'set:thinking': (data: SetThinkingPayload) => void;
|
'set:thinking': (data: SetThinkingPayload) => void;
|
||||||
'command:execute': (data: SlashCommandPayload) => void;
|
'command:execute': (data: SlashCommandPayload) => void;
|
||||||
|
'command:approve': (data: SlashCommandPayload) => void;
|
||||||
abort: (data: AbortPayload) => void;
|
abort: (data: AbortPayload) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ export interface SlashCommandPayload {
|
|||||||
conversationId: string;
|
conversationId: string;
|
||||||
command: string;
|
command: string;
|
||||||
args?: string;
|
args?: string;
|
||||||
|
/** One-time server-issued approval for a privileged command execution. */
|
||||||
|
approvalId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Server response to a request to approve a privileged slash command. */
|
||||||
|
export interface SlashCommandApprovalResultPayload {
|
||||||
|
conversationId: string;
|
||||||
|
command: string;
|
||||||
|
success: boolean;
|
||||||
|
approvalId?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Server response to a slash command */
|
/** Server response to a slash command */
|
||||||
|
|||||||
@@ -1,24 +1,109 @@
|
|||||||
|
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||||
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
import { ChannelType, Client, GatewayIntentBits, type Message as DiscordMessage } from 'discord.js';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
|
|
||||||
export interface DiscordPluginConfig {
|
export interface DiscordPluginConfig {
|
||||||
token: string;
|
token: string;
|
||||||
gatewayUrl: string;
|
gatewayUrl: string;
|
||||||
/** Which guild to bind to (single-guild only for v0.1.0) */
|
/** Shared service credential injected by the approved secret mechanism. */
|
||||||
|
serviceToken: string;
|
||||||
|
/** Which guild to bind to (single-guild only for v0.1.0). */
|
||||||
guildId?: string;
|
guildId?: string;
|
||||||
|
allowedGuildIds: readonly string[];
|
||||||
|
allowedChannelIds: readonly string[];
|
||||||
|
allowedUserIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordIngressPayload {
|
||||||
|
correlationId: string;
|
||||||
|
messageId: string;
|
||||||
|
guildId: string;
|
||||||
|
channelId: string;
|
||||||
|
userId: string;
|
||||||
|
conversationId: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordIngressEnvelope {
|
||||||
|
payload: DiscordIngressPayload;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordIngressAllowlists {
|
||||||
|
guildIds: readonly string[];
|
||||||
|
channelIds: readonly string[];
|
||||||
|
userIds: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function signedPayload(payload: DiscordIngressPayload): string {
|
||||||
|
return [
|
||||||
|
payload.correlationId,
|
||||||
|
payload.messageId,
|
||||||
|
payload.guildId,
|
||||||
|
payload.channelId,
|
||||||
|
payload.userId,
|
||||||
|
payload.conversationId,
|
||||||
|
payload.content,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signPayload(payload: DiscordIngressPayload, serviceToken: string): string {
|
||||||
|
return createHmac('sha256', serviceToken).update(signedPayload(payload)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignatureValid(actual: string, expected: string): boolean {
|
||||||
|
const actualBuffer = Buffer.from(actual, 'hex');
|
||||||
|
const expectedBuffer = Buffer.from(expected, 'hex');
|
||||||
|
return (
|
||||||
|
actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesId(allowedIds: readonly string[], id: string): boolean {
|
||||||
|
return allowedIds.includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates the signed, auditable envelope accepted by the gateway Discord service boundary. */
|
||||||
|
export function createDiscordIngressEnvelope(
|
||||||
|
payload: DiscordIngressPayload,
|
||||||
|
serviceToken: string,
|
||||||
|
): DiscordIngressEnvelope {
|
||||||
|
return { payload, signature: signPayload(payload, serviceToken) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies service-origin integrity and applies default-deny Discord identity allowlists.
|
||||||
|
* Returns null instead of a partially trusted payload on every failure path.
|
||||||
|
*/
|
||||||
|
export function verifyDiscordIngressEnvelope(
|
||||||
|
envelope: DiscordIngressEnvelope,
|
||||||
|
serviceToken: string,
|
||||||
|
allowlists?: DiscordIngressAllowlists,
|
||||||
|
): DiscordIngressPayload | null {
|
||||||
|
const expectedSignature = signPayload(envelope.payload, serviceToken);
|
||||||
|
if (!isSignatureValid(envelope.signature, expectedSignature)) return null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
allowlists &&
|
||||||
|
(!includesId(allowlists.guildIds, envelope.payload.guildId) ||
|
||||||
|
!includesId(allowlists.channelIds, envelope.payload.channelId) ||
|
||||||
|
!includesId(allowlists.userIds, envelope.payload.userId))
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return envelope.payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DiscordPlugin {
|
export class DiscordPlugin {
|
||||||
private client: Client;
|
private client: Client;
|
||||||
private socket: Socket | null = null;
|
private socket: Socket | null = null;
|
||||||
private config: DiscordPluginConfig;
|
/** Map Discord channel ID → Mosaic conversation ID. */
|
||||||
/** Map Discord channel ID → Mosaic conversation ID */
|
|
||||||
private channelConversations = new Map<string, string>();
|
private channelConversations = new Map<string, string>();
|
||||||
/** Track in-flight responses to avoid duplicate streaming */
|
/** Track in-flight responses to avoid duplicate streaming. */
|
||||||
private pendingResponses = new Map<string, string>();
|
private pendingResponses = new Map<string, string>();
|
||||||
|
|
||||||
constructor(config: DiscordPluginConfig) {
|
constructor(private readonly config: DiscordPluginConfig) {
|
||||||
this.config = config;
|
|
||||||
this.client = new Client({
|
this.client = new Client({
|
||||||
intents: [
|
intents: [
|
||||||
GatewayIntentBits.Guilds,
|
GatewayIntentBits.Guilds,
|
||||||
@@ -30,8 +115,8 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async start(): Promise<void> {
|
async start(): Promise<void> {
|
||||||
// Connect to gateway WebSocket
|
|
||||||
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
this.socket = io(`${this.config.gatewayUrl}/chat`, {
|
||||||
|
auth: { discordServiceToken: this.config.serviceToken },
|
||||||
transports: ['websocket'],
|
transports: ['websocket'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -48,7 +133,6 @@ export class DiscordPlugin {
|
|||||||
console.error(`[discord] Gateway connection error: ${err.message}`);
|
console.error(`[discord] Gateway connection error: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle streaming text from gateway
|
|
||||||
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
this.socket.on('agent:text', (data: { conversationId: string; text: string }) => {
|
||||||
const pending = this.pendingResponses.get(data.conversationId);
|
const pending = this.pendingResponses.get(data.conversationId);
|
||||||
if (pending !== undefined) {
|
if (pending !== undefined) {
|
||||||
@@ -56,12 +140,11 @@ export class DiscordPlugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// When agent finishes, send the accumulated response
|
|
||||||
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
this.socket.on('agent:end', (data: { conversationId: string }) => {
|
||||||
const text = this.pendingResponses.get(data.conversationId);
|
const text = this.pendingResponses.get(data.conversationId);
|
||||||
if (text) {
|
if (text) {
|
||||||
this.pendingResponses.delete(data.conversationId);
|
this.pendingResponses.delete(data.conversationId);
|
||||||
this.sendToDiscord(data.conversationId, text).catch((err) => {
|
this.sendToDiscord(data.conversationId, text).catch((err: unknown) => {
|
||||||
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
console.error(`[discord] Error sending response for ${data.conversationId}:`, err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -71,8 +154,9 @@ export class DiscordPlugin {
|
|||||||
this.pendingResponses.set(data.conversationId, '');
|
this.pendingResponses.set(data.conversationId, '');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set up Discord message handler
|
this.client.on('messageCreate', (message: DiscordMessage) =>
|
||||||
this.client.on('messageCreate', (message) => this.handleDiscordMessage(message));
|
this.handleDiscordMessage(message),
|
||||||
|
);
|
||||||
|
|
||||||
this.client.on('ready', () => {
|
this.client.on('ready', () => {
|
||||||
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
console.log(`[discord] Bot logged in as ${this.client.user?.tag}`);
|
||||||
@@ -96,7 +180,6 @@ export class DiscordPlugin {
|
|||||||
const guild = this.client.guilds.cache.get(this.config.guildId);
|
const guild = this.client.guilds.cache.get(this.config.guildId);
|
||||||
if (!guild) return null;
|
if (!guild) return null;
|
||||||
|
|
||||||
// Slugify project name for channel: lowercase, replace spaces/special chars with hyphens
|
|
||||||
const channelName = `mosaic-${project.name
|
const channelName = `mosaic-${project.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
@@ -108,58 +191,58 @@ export class DiscordPlugin {
|
|||||||
topic: project.description ?? `Mosaic project: ${project.name}`,
|
topic: project.description ?? `Mosaic project: ${project.name}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register the channel mapping so messages route correctly
|
|
||||||
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
this.channelConversations.set(channel.id, `discord-${channel.id}`);
|
||||||
|
|
||||||
return { channelId: channel.id };
|
return { channelId: channel.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleDiscordMessage(message: DiscordMessage): void {
|
private handleDiscordMessage(message: DiscordMessage): void {
|
||||||
// Ignore bot messages
|
if (message.author.bot || !this.client.user) return;
|
||||||
if (message.author.bot) return;
|
if (!message.guildId || !this.isAllowedMessage(message)) return;
|
||||||
|
|
||||||
// Not ready yet
|
|
||||||
if (!this.client.user) return;
|
|
||||||
|
|
||||||
// Check guild binding
|
|
||||||
if (this.config.guildId && message.guildId !== this.config.guildId) return;
|
|
||||||
|
|
||||||
// Respond to DMs always, or mentions in channels
|
|
||||||
const isDM = !message.guildId;
|
|
||||||
const isMention = message.mentions.has(this.client.user);
|
const isMention = message.mentions.has(this.client.user);
|
||||||
|
if (!isMention) return;
|
||||||
|
|
||||||
if (!isDM && !isMention) return;
|
|
||||||
|
|
||||||
// Strip bot mention from message content
|
|
||||||
const content = message.content
|
const content = message.content
|
||||||
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
.replace(new RegExp(`<@!?${this.client.user.id}>`, 'g'), '')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
if (!content) return;
|
if (!content) return;
|
||||||
|
|
||||||
// Get or create conversation for this Discord channel
|
|
||||||
const channelId = message.channelId;
|
|
||||||
let conversationId = this.channelConversations.get(channelId);
|
|
||||||
if (!conversationId) {
|
|
||||||
conversationId = `discord-${channelId}`;
|
|
||||||
this.channelConversations.set(channelId, conversationId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send to gateway
|
|
||||||
if (!this.socket?.connected) {
|
if (!this.socket?.connected) {
|
||||||
console.error(
|
console.error(
|
||||||
`[discord] Cannot forward message: not connected to gateway. channel=${channelId}`,
|
`[discord] Cannot forward message: not connected to gateway. channel=${message.channelId} message=${message.id}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.socket.emit('message', {
|
|
||||||
conversationId,
|
const channelId = message.channelId;
|
||||||
content,
|
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> {
|
private async sendToDiscord(conversationId: string, text: string): Promise<void> {
|
||||||
// Find the Discord channel for this conversation
|
|
||||||
const channelId = Array.from(this.channelConversations.entries()).find(
|
const channelId = Array.from(this.channelConversations.entries()).find(
|
||||||
([, convId]) => convId === conversationId,
|
([, convId]) => convId === conversationId,
|
||||||
)?.[0];
|
)?.[0];
|
||||||
@@ -177,12 +260,10 @@ export class DiscordPlugin {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chunk responses for Discord's 2000-char limit
|
for (const chunk of this.chunkText(text, 1900)) {
|
||||||
const chunks = this.chunkText(text, 1900);
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
try {
|
try {
|
||||||
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
await (channel as { send: (content: string) => Promise<unknown> }).send(chunk);
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
console.error(`[discord] Failed to send message to channel ${channelId}:`, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,21 +274,16 @@ export class DiscordPlugin {
|
|||||||
|
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
let remaining = text;
|
let remaining = text;
|
||||||
|
|
||||||
while (remaining.length > 0) {
|
while (remaining.length > 0) {
|
||||||
if (remaining.length <= maxLength) {
|
if (remaining.length <= maxLength) {
|
||||||
chunks.push(remaining);
|
chunks.push(remaining);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to break at a newline
|
|
||||||
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
let breakPoint = remaining.lastIndexOf('\n', maxLength);
|
||||||
if (breakPoint <= 0) breakPoint = maxLength;
|
if (breakPoint <= 0) breakPoint = maxLength;
|
||||||
|
|
||||||
chunks.push(remaining.slice(0, breakPoint));
|
chunks.push(remaining.slice(0, breakPoint));
|
||||||
remaining = remaining.slice(breakPoint).trimStart();
|
remaining = remaining.slice(breakPoint).trimStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
return chunks;
|
return chunks;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ importers:
|
|||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
specifier: ^1.27.1
|
specifier: ^1.27.1
|
||||||
version: 1.27.1(zod@4.3.6)
|
version: 1.27.1(zod@4.3.6)
|
||||||
|
'@mosaicstack/agent':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/agent
|
||||||
'@mosaicstack/auth':
|
'@mosaicstack/auth':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/auth
|
version: link:../../packages/auth
|
||||||
|
|||||||
Reference in New Issue
Block a user