docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending) #741
@@ -0,0 +1,192 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { InMemoryDurableSessionStore } from '@mosaicstack/agent';
|
||||
import {
|
||||
createDiscordIngressEnvelope,
|
||||
DiscordPlugin,
|
||||
type DiscordIngressPayload,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
|
||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||
|
||||
const SERVICE_TOKEN = 'test-discord-service-token';
|
||||
const envKeys = [
|
||||
'DISCORD_SERVICE_TOKEN',
|
||||
'DISCORD_SERVICE_TENANT_ID',
|
||||
'DISCORD_INTERACTION_BINDINGS',
|
||||
'DISCORD_ALLOWED_GUILD_IDS',
|
||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||
'DISCORD_ALLOWED_USER_IDS',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
] as const;
|
||||
const priorEnv = new Map<string, string | undefined>();
|
||||
|
||||
function payload(content: string, messageId: string, correlationId: string): DiscordIngressPayload {
|
||||
return {
|
||||
content,
|
||||
messageId,
|
||||
correlationId,
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
userId: 'discord-admin-1',
|
||||
conversationId: 'Nova:discord:channel-1',
|
||||
};
|
||||
}
|
||||
|
||||
function authorization(): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
return new CommandAuthorizationService(
|
||||
{
|
||||
select: () => ({
|
||||
from: () => ({ where: () => ({ limit: async () => [{ role: 'admin' }] }) }),
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
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)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('Tess Discord/CLI durable-session integration', () => {
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
const value = priorEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
priorEnv.clear();
|
||||
});
|
||||
|
||||
it('enrolls through the CLI surface then resolves the same durable session from Discord', async () => {
|
||||
for (const key of envKeys) priorEnv.set(key, process.env[key]);
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-1';
|
||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-1';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-1';
|
||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'discord-admin-1';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
pairedUsers: {
|
||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const durable = new TessDurableSessionService(
|
||||
new InMemoryDurableSessionStore() as never,
|
||||
{} as never,
|
||||
);
|
||||
const enrollmentRuntime = {
|
||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||
};
|
||||
const controller = new InteractionController(enrollmentRuntime as never, durable);
|
||||
await controller.enroll(
|
||||
'Nova',
|
||||
'Nova:discord:channel-1',
|
||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
{ id: 'mosaic-admin-1', tenantId: 'tenant-1' },
|
||||
'cli-enrollment-correlation',
|
||||
);
|
||||
|
||||
const authz = authorization();
|
||||
const terminated = vi.fn().mockResolvedValue(undefined);
|
||||
const runtime = new RuntimeProviderService(
|
||||
{
|
||||
require: () => ({
|
||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||
terminate: terminated,
|
||||
}),
|
||||
} as never,
|
||||
{ record: async () => undefined } as never,
|
||||
{
|
||||
consume: (approvalId, action) =>
|
||||
authz.consumeRuntimeTerminationApproval(approvalId, action),
|
||||
},
|
||||
);
|
||||
const gateway = new ChatGateway(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
authz,
|
||||
runtime,
|
||||
durable,
|
||||
);
|
||||
const client = { data: { discordService: true }, emit: vi.fn() };
|
||||
const plugin = new DiscordPlugin({
|
||||
token: 'unused',
|
||||
gatewayUrl: 'http://unused',
|
||||
serviceToken: SERVICE_TOKEN,
|
||||
allowedGuildIds: ['guild-1'],
|
||||
allowedChannelIds: ['channel-1'],
|
||||
allowedUserIds: ['discord-admin-1'],
|
||||
interactionBindings: [
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
pairedUsers: {
|
||||
'discord-admin-1': { role: 'admin', mosaicUserId: 'mosaic-admin-1' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const pluginInternals = plugin as unknown as {
|
||||
client: { user: { id: string } };
|
||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||
handleDiscordMessage(message: unknown): void;
|
||||
};
|
||||
const pluginSocket = { connected: true, emit: vi.fn() };
|
||||
pluginInternals.client = { user: { id: 'bot-1' } };
|
||||
pluginInternals.socket = pluginSocket;
|
||||
pluginInternals.handleDiscordMessage({
|
||||
id: 'approve-1',
|
||||
guildId: 'guild-1',
|
||||
channelId: 'channel-1',
|
||||
author: { id: 'discord-admin-1', bot: false },
|
||||
mentions: { has: () => true },
|
||||
content: '<@bot-1> /approve',
|
||||
channel: { parentId: null },
|
||||
attachments: new Map(),
|
||||
});
|
||||
|
||||
expect(pluginSocket.emit).toHaveBeenCalledWith('discord:approve', expect.any(Object));
|
||||
const approvalEnvelope = pluginSocket.emit.mock.calls[0]?.[1];
|
||||
await gateway.handleDiscordApproval(client as never, approvalEnvelope);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
)?.[1] as {
|
||||
approvalId: string;
|
||||
success: boolean;
|
||||
};
|
||||
expect(approval.success).toBe(true);
|
||||
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
createDiscordIngressEnvelope(
|
||||
payload(`/stop ${approval.approvalId}`, 'stop-1', 'discord-stop-correlation'),
|
||||
SERVICE_TOKEN,
|
||||
),
|
||||
);
|
||||
|
||||
expect(terminated).toHaveBeenCalledWith(
|
||||
'runtime-1',
|
||||
approval.approvalId,
|
||||
expect.objectContaining({ actorId: 'mosaic-admin-1' }),
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||
correlationId: 'discord-stop-correlation',
|
||||
success: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,18 +12,24 @@ type AgentServiceInternals = {
|
||||
creating: Map<string, Promise<AgentSession>>;
|
||||
};
|
||||
|
||||
function makeService(): AgentService {
|
||||
function makeService(operatorMemory: unknown = null): AgentService {
|
||||
return new AgentService(
|
||||
{} as never,
|
||||
{
|
||||
getDefaultModel: vi.fn(() => null),
|
||||
getRegistry: vi.fn(() => ({})),
|
||||
findModel: vi.fn(),
|
||||
listAvailableModels: vi.fn(() => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ available: false } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
||||
null,
|
||||
null,
|
||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||
operatorMemory as never,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,6 +126,37 @@ describe('AgentService owner/tenant scope enforcement', () => {
|
||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
||||
});
|
||||
|
||||
it('derives the operator-memory scope on the createSession production path', async () => {
|
||||
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||
const service = makeService(plugin);
|
||||
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox').mockReturnValue([]);
|
||||
|
||||
// Session construction reaches the real scope derivation before the intentionally incomplete
|
||||
// Pi test double rejects later in createAgentSession.
|
||||
await service.createSession(CONVERSATION_ID, OWNER_SCOPE).catch(() => undefined);
|
||||
|
||||
expect(buildTools).toHaveBeenCalledWith(expect.any(String), OWNER_SCOPE.userId, {
|
||||
tenantId: OWNER_SCOPE.tenantId,
|
||||
ownerId: OWNER_SCOPE.userId,
|
||||
sessionId: CONVERSATION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('denies a foreign actor before it can obtain another session operator-memory scope', async () => {
|
||||
const plugin = { capture: vi.fn(), search: vi.fn() };
|
||||
const service = makeService(plugin);
|
||||
internals(service).sessions.set(CONVERSATION_ID, makeSession());
|
||||
const buildTools = vi.spyOn(service as never, 'buildToolsForSandbox');
|
||||
|
||||
await expect(service.createSession(CONVERSATION_ID, FOREIGN_SCOPE)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
|
||||
expect(buildTools).not.toHaveBeenCalled();
|
||||
expect(plugin.capture).not.toHaveBeenCalled();
|
||||
expect(plugin.search).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||
const service = makeService();
|
||||
const session = makeSession();
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type RuntimeApprovalVerifier,
|
||||
} from '../runtime-provider-registry.service.js';
|
||||
|
||||
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
|
||||
|
||||
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
||||
const CONTEXT = {
|
||||
actorScope: OWNER_SCOPE,
|
||||
@@ -35,6 +37,7 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
||||
readonly sentMessages: RuntimeMessage[] = [];
|
||||
terminateCalls = 0;
|
||||
throwAfterSend = false;
|
||||
throwAuthorization = false;
|
||||
|
||||
constructor(private readonly supported: RuntimeCapability[]) {}
|
||||
|
||||
@@ -74,6 +77,9 @@ class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
||||
): Promise<void> {
|
||||
this.receivedScopes.push(scope);
|
||||
this.sentMessages.push(message);
|
||||
if (this.throwAuthorization) {
|
||||
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
|
||||
}
|
||||
if (this.throwAfterSend) {
|
||||
throw new Error('provider acknowledgement failed');
|
||||
}
|
||||
@@ -243,6 +249,7 @@ describe('RuntimeProviderService security boundary', (): void => {
|
||||
tenantId: OWNER_SCOPE.tenantId,
|
||||
channelId: CONTEXT.channelId,
|
||||
correlationId: CONTEXT.correlationId,
|
||||
agentName: process.env['MOSAIC_AGENT_NAME'],
|
||||
});
|
||||
expect(provider.terminateCalls).toBe(1);
|
||||
});
|
||||
@@ -292,6 +299,23 @@ describe('RuntimeProviderService security boundary', (): void => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
|
||||
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||
provider.throwAuthorization = 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 authorization denied/);
|
||||
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
||||
});
|
||||
|
||||
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
|
||||
let persisted: unknown;
|
||||
const ingest = async (entry: unknown): Promise<unknown> => {
|
||||
|
||||
@@ -9,15 +9,19 @@ import { SkillLoaderService } from './skill-loader.service.js';
|
||||
import { ProvidersController } from './providers.controller.js';
|
||||
import { SessionsController } from './sessions.controller.js';
|
||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||
import { InteractionController } from './interaction.controller.js';
|
||||
import { RoutingController } from './routing/routing.controller.js';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
import { CoordModule } from '../coord/coord.module.js';
|
||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||
import { SkillsModule } from '../skills/skills.module.js';
|
||||
import { GCModule } from '../gc/gc.module.js';
|
||||
import { LogModule } from '../log/log.module.js';
|
||||
import { CommandsModule } from '../commands/commands.module.js';
|
||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||
import {
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
DenyRuntimeApprovalVerifier,
|
||||
RUNTIME_APPROVAL_VERIFIER,
|
||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
RuntimeProviderAuditService,
|
||||
@@ -26,13 +30,15 @@ import {
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule],
|
||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
||||
providers: [
|
||||
ProviderService,
|
||||
ProviderCredentialsService,
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
TessDurableSessionRepository,
|
||||
TessDurableSessionService,
|
||||
{
|
||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
||||
@@ -42,15 +48,20 @@ import {
|
||||
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
useExisting: RuntimeProviderAuditService,
|
||||
},
|
||||
DenyRuntimeApprovalVerifier,
|
||||
{
|
||||
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||
useExisting: DenyRuntimeApprovalVerifier,
|
||||
useExisting: CommandRuntimeApprovalVerifier,
|
||||
},
|
||||
RuntimeProviderService,
|
||||
AgentService,
|
||||
],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||
controllers: [
|
||||
ProvidersController,
|
||||
SessionsController,
|
||||
AgentConfigsController,
|
||||
InteractionController,
|
||||
RoutingController,
|
||||
],
|
||||
exports: [
|
||||
AgentService,
|
||||
ProviderService,
|
||||
@@ -58,6 +69,7 @@ import {
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
TessDurableSessionService,
|
||||
RuntimeProviderService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
type ToolDefinition,
|
||||
} from '@mariozechner/pi-coding-agent';
|
||||
import type { Brain } from '@mosaicstack/brain';
|
||||
import type { Memory } from '@mosaicstack/memory';
|
||||
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { MEMORY } from '../memory/memory.tokens.js';
|
||||
import { OPERATOR_MEMORY_PLUGIN } from '../memory/memory.module.js';
|
||||
import { EmbeddingService } from '../memory/embedding.service.js';
|
||||
import { CoordService } from '../coord/coord.service.js';
|
||||
import { ProviderService } from './provider.service.js';
|
||||
@@ -135,6 +136,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
@Inject(PreferencesService)
|
||||
private readonly preferencesService: PreferencesService | null,
|
||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||
@Optional()
|
||||
@Inject(OPERATOR_MEMORY_PLUGIN)
|
||||
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -146,6 +150,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
private buildToolsForSandbox(
|
||||
sandboxDir: string,
|
||||
sessionUserId: string | undefined,
|
||||
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
||||
): ToolDefinition[] {
|
||||
return [
|
||||
...createBrainTools(this.brain),
|
||||
@@ -154,6 +159,9 @@ export class AgentService implements OnModuleDestroy {
|
||||
this.memory,
|
||||
this.embeddingService.available ? this.embeddingService : null,
|
||||
sessionUserId,
|
||||
this.operatorMemory && sessionScope
|
||||
? { plugin: this.operatorMemory, scope: sessionScope }
|
||||
: undefined,
|
||||
),
|
||||
...createFileTools(sandboxDir),
|
||||
...createGitTools(sandboxDir),
|
||||
@@ -228,6 +236,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
isAdmin: options.isAdmin,
|
||||
agentConfigId: options.agentConfigId,
|
||||
userId: options.userId,
|
||||
tenantId: options.tenantId,
|
||||
conversationHistory: options.conversationHistory,
|
||||
};
|
||||
this.logger.log(
|
||||
@@ -267,7 +276,15 @@ export class AgentService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||
const sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId);
|
||||
const sessionUserId = mergedOptions?.userId;
|
||||
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
|
||||
const sandboxTools = this.buildToolsForSandbox(
|
||||
sandboxDir,
|
||||
sessionUserId,
|
||||
sessionUserId && sessionTenantId
|
||||
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||
@@ -362,7 +379,7 @@ export class AgentService implements OnModuleDestroy {
|
||||
sandboxDir,
|
||||
allowedTools,
|
||||
userId: mergedOptions?.userId,
|
||||
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
||||
tenantId: sessionTenantId,
|
||||
agentConfigId: mergedOptions?.agentConfigId,
|
||||
agentName: resolvedAgentName,
|
||||
metrics: {
|
||||
|
||||
233
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
233
apps/gateway/src/agent/interaction.controller.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||
import { InteractionController } from './interaction.controller.js';
|
||||
|
||||
describe('InteractionController', (): void => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('maps a denied runtime approval to Fastify HTTP 403', () => {
|
||||
const send = vi.fn();
|
||||
const status = vi.fn().mockReturnValue({ send });
|
||||
const response = { status };
|
||||
const host = { switchToHttp: () => ({ getResponse: () => response }) };
|
||||
|
||||
new RuntimeApprovalDeniedFilter().catch(new RuntimeApprovalDeniedError(), host as never);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(403);
|
||||
expect(send).toHaveBeenCalledWith({
|
||||
statusCode: 403,
|
||||
message: 'Runtime termination approval denied',
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a differently named configured instance without a code change', async () => {
|
||||
const prior = process.env['MOSAIC_AGENT_NAME'];
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { listSessions: vi.fn().mockResolvedValue([]) };
|
||||
const controller = new InteractionController(runtime as never, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.sessions('Nova', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||
).resolves.toEqual([]);
|
||||
await expect(
|
||||
controller.sessions('Other', 'fleet', { id: 'owner', tenantId: 'team' }, 'corr-1'),
|
||||
).rejects.toThrow('Interaction agent is not configured');
|
||||
|
||||
if (prior === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
||||
});
|
||||
|
||||
it('rejects a request without the non-simple correlation header', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||
|
||||
await expect(controller.sessions('Nova', 'fleet', { id: 'owner' })).rejects.toThrow(
|
||||
'X-Correlation-Id is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('enrolls a visible runtime session under the cross-surface conversation handle', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = {
|
||||
listSessions: vi.fn().mockResolvedValue([{ id: 'runtime-1' }]),
|
||||
};
|
||||
const durable = { enroll: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await expect(
|
||||
controller.enroll(
|
||||
'Nova',
|
||||
'conversation-1',
|
||||
{ providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
{ id: 'owner', tenantId: 'team' },
|
||||
'corr-1',
|
||||
),
|
||||
).resolves.toEqual({ status: 'enrolled', sessionId: 'conversation-1' });
|
||||
expect(durable.enroll).toHaveBeenCalledWith(
|
||||
{
|
||||
agentName: 'Nova',
|
||||
sessionId: 'conversation-1',
|
||||
tenantId: 'team',
|
||||
ownerId: 'owner',
|
||||
providerId: 'fleet',
|
||||
runtimeSessionId: 'runtime-1',
|
||||
},
|
||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an invalid attach mode before invoking a provider', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const controller = new InteractionController({ attach: vi.fn() } as never, {} as never);
|
||||
|
||||
await expect(
|
||||
controller.attach('Nova', 'durable-1', { mode: 'write' as never }, { id: 'owner' }, 'corr-1'),
|
||||
).rejects.toThrow('Interaction attach mode is invalid');
|
||||
});
|
||||
|
||||
it('resumes a durable session by attaching and streaming its runtime events', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtimeEvent = {
|
||||
type: 'message.delta' as const,
|
||||
sessionId: 'runtime-1',
|
||||
cursor: 'cursor-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
content: 'resumed',
|
||||
};
|
||||
const runtime = {
|
||||
attach: vi.fn().mockResolvedValue({ attachmentId: 'attach-1', sessionId: 'runtime-1' }),
|
||||
streamSession: vi.fn(async function* () {
|
||||
yield runtimeEvent;
|
||||
}),
|
||||
};
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await controller.attach('Nova', 'conversation-1', { mode: 'read' }, { id: 'owner' }, 'corr-1');
|
||||
await expect(
|
||||
firstValueFrom(
|
||||
controller.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1'),
|
||||
),
|
||||
).resolves.toEqual({ data: runtimeEvent });
|
||||
|
||||
expect(runtime.attach).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
'read',
|
||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||
);
|
||||
expect(runtime.streamSession).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
undefined,
|
||||
expect.objectContaining({ correlationId: 'corr-1' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create a runtime stream after the SSE subscriber disconnects during snapshot lookup', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
let resolveSnapshot!: (value: { identity: Record<string, string> }) => void;
|
||||
const snapshot = new Promise<{ identity: Record<string, string> }>((resolve) => {
|
||||
resolveSnapshot = resolve;
|
||||
});
|
||||
const runtime = { streamSession: vi.fn() };
|
||||
const durable = { getSnapshot: vi.fn().mockReturnValue(snapshot) };
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
const subscription = controller
|
||||
.stream('Nova', 'conversation-1', undefined, { id: 'owner' }, 'corr-1')
|
||||
.subscribe();
|
||||
subscription.unsubscribe();
|
||||
resolveSnapshot({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(runtime.streamSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['wrong actor', { getSnapshot: vi.fn().mockRejectedValue(new Error('scope mismatch')) }],
|
||||
[
|
||||
'session-agent mismatch',
|
||||
{
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
},
|
||||
],
|
||||
])('denies a CLI stop for %s', async (_reason, durable) => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await expect(
|
||||
controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
),
|
||||
).rejects.toBeDefined();
|
||||
expect(runtime.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('surfaces a denied runtime approval to the CLI interaction surface', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = {
|
||||
terminate: vi.fn().mockRejectedValue(new Error('Runtime termination approval denied')),
|
||||
};
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await expect(
|
||||
controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
),
|
||||
).rejects.toThrow('Runtime termination approval denied');
|
||||
});
|
||||
|
||||
it('uses the durable session identity and runtime registry for an approved stop', async () => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
const runtime = { terminate: vi.fn().mockResolvedValue(undefined) };
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const controller = new InteractionController(runtime as never, durable as never);
|
||||
|
||||
await controller.stop(
|
||||
'Nova',
|
||||
'durable-1',
|
||||
{ approvalRef: 'approval-1' },
|
||||
{ id: 'owner' },
|
||||
'corr-1',
|
||||
);
|
||||
|
||||
expect(runtime.terminate).toHaveBeenCalledWith(
|
||||
'fleet',
|
||||
'runtime-1',
|
||||
'approval-1',
|
||||
expect.objectContaining({
|
||||
correlationId: 'corr-1',
|
||||
actorScope: { userId: 'owner', tenantId: 'owner' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
279
apps/gateway/src/agent/interaction.controller.ts
Normal file
279
apps/gateway/src/agent/interaction.controller.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
Sse,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseFilters,
|
||||
} from '@nestjs/common';
|
||||
import type { RuntimeAttachMode, RuntimeStreamEvent } from '@mosaicstack/types';
|
||||
import { Observable } from 'rxjs';
|
||||
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 { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Authenticated HTTP boundary for operator interaction clients. Identity is
|
||||
* selected from deployment configuration, never a client-side command name.
|
||||
*/
|
||||
@Controller('api/interaction/:agentName')
|
||||
@UseGuards(AuthGuard)
|
||||
@UseFilters(RuntimeApprovalDeniedFilter)
|
||||
export class InteractionController {
|
||||
constructor(
|
||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
||||
) {}
|
||||
|
||||
@Get('sessions')
|
||||
async sessions(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.listSessions(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('tree')
|
||||
async tree(
|
||||
@Param('agentName') agentName: string,
|
||||
@Query('provider') providerId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
return this.runtime.getSessionTree(
|
||||
this.requiredProvider(providerId),
|
||||
this.context(user, correlationId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind an existing, authorized runtime session to the stable conversation ID.
|
||||
* This is the lifecycle boundary where both runtime identifiers are known.
|
||||
*/
|
||||
@Post('sessions/:sessionId/enroll')
|
||||
async enroll(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { providerId?: string; runtimeSessionId?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const providerId = this.requiredProvider(body.providerId ?? '');
|
||||
const runtimeSessionId = body.runtimeSessionId?.trim();
|
||||
if (!runtimeSessionId) throw new ForbiddenException('Runtime session identity is required');
|
||||
const context = this.context(user, correlationId);
|
||||
const sessions = await this.runtime.listSessions(providerId, context);
|
||||
if (!sessions.some((session): boolean => session.id === runtimeSessionId)) {
|
||||
throw new ForbiddenException('Runtime session is not visible to this actor');
|
||||
}
|
||||
await this.durable.enroll(
|
||||
{
|
||||
agentName,
|
||||
sessionId,
|
||||
tenantId: context.actorScope.tenantId,
|
||||
ownerId: context.actorScope.userId,
|
||||
providerId,
|
||||
runtimeSessionId,
|
||||
},
|
||||
context,
|
||||
);
|
||||
return { status: 'enrolled', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/attach')
|
||||
async attach(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { mode?: RuntimeAttachMode } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
const mode = body.mode ?? 'read';
|
||||
if (mode !== 'read' && mode !== 'control') {
|
||||
throw new ForbiddenException('Interaction attach mode is invalid');
|
||||
}
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
return this.runtime.attach(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
mode,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
@Sse('sessions/:sessionId/stream')
|
||||
stream(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Observable<{ data: RuntimeStreamEvent }> {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
return new Observable((subscriber) => {
|
||||
let iterator: AsyncIterator<RuntimeStreamEvent> | undefined;
|
||||
let cancelled = false;
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
if (cancelled || subscriber.closed) return;
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
iterator = this.runtime
|
||||
.streamSession(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
cursor?.trim() || undefined,
|
||||
context,
|
||||
)
|
||||
[Symbol.asyncIterator]();
|
||||
if (cancelled || subscriber.closed) {
|
||||
await iterator.return?.();
|
||||
return;
|
||||
}
|
||||
while (!cancelled && !subscriber.closed) {
|
||||
const next = await iterator.next();
|
||||
if (next.done || cancelled || subscriber.closed) break;
|
||||
subscriber.next({ data: next.value });
|
||||
}
|
||||
if (!subscriber.closed) subscriber.complete();
|
||||
} catch (error: unknown) {
|
||||
if (!subscriber.closed) subscriber.error(error);
|
||||
}
|
||||
})();
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
void iterator?.return?.();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/send')
|
||||
async send(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { content?: string; idempotencyKey?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
if (!body.content?.trim() || !body.idempotencyKey?.trim()) {
|
||||
throw new ForbiddenException('Content and idempotency key are required');
|
||||
}
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
const input = {
|
||||
sessionId,
|
||||
content: body.content,
|
||||
idempotencyKey: body.idempotencyKey,
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
};
|
||||
await this.durable.queueProviderSend(input);
|
||||
await this.durable.dispatchProviderOutbox(sessionId, input);
|
||||
return { status: 'queued', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/stop')
|
||||
async stop(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() body: { approvalRef?: string } = {},
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
if (!body.approvalRef?.trim())
|
||||
throw new ForbiddenException('Exact-action approval is required');
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
await this.runtime.terminate(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
body.approvalRef,
|
||||
context,
|
||||
);
|
||||
return { status: 'stopped', sessionId };
|
||||
}
|
||||
|
||||
@Post('sessions/:sessionId/recover')
|
||||
async recover(
|
||||
@Param('agentName') agentName: string,
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
) {
|
||||
this.assertConfiguredAgent(agentName);
|
||||
const context = this.context(user, correlationId);
|
||||
const snapshot = await this.durable.getSnapshot(sessionId, context);
|
||||
this.assertSessionAgent(snapshot.identity.agentName, agentName);
|
||||
await this.durable.recoverProviderSession(sessionId, {
|
||||
sessionId,
|
||||
content: '',
|
||||
idempotencyKey: `recovery:${context.correlationId}`,
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
});
|
||||
return { status: 'recovered', sessionId };
|
||||
}
|
||||
|
||||
private context(
|
||||
user: AuthenticatedUserLike,
|
||||
correlationId?: string,
|
||||
): RuntimeProviderRequestContext {
|
||||
const requestCorrelationId = correlationId?.trim();
|
||||
// This non-simple request header is mandatory for mutations. Browser
|
||||
// cross-origin requests cannot set it without a CORS preflight, and the
|
||||
// gateway's allowlist rejects untrusted origins before the handler runs.
|
||||
if (!requestCorrelationId) {
|
||||
throw new ForbiddenException('X-Correlation-Id is required');
|
||||
}
|
||||
return {
|
||||
actorScope: scopeFromUser(user),
|
||||
channelId: 'cli',
|
||||
correlationId: requestCorrelationId,
|
||||
};
|
||||
}
|
||||
|
||||
private assertConfiguredAgent(agentName: string): void {
|
||||
const configured = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!configured || configured !== agentName) {
|
||||
throw new ForbiddenException('Interaction agent is not configured for this request');
|
||||
}
|
||||
}
|
||||
|
||||
private assertSessionAgent(sessionAgentName: string, agentName: string): void {
|
||||
if (sessionAgentName !== agentName)
|
||||
throw new ForbiddenException('Interaction session identity mismatch');
|
||||
}
|
||||
|
||||
private requiredProvider(providerId: string): string {
|
||||
if (!providerId?.trim()) throw new ForbiddenException('Runtime provider is required');
|
||||
return providerId;
|
||||
}
|
||||
}
|
||||
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
13
apps/gateway/src/agent/runtime-approval-denied.filter.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Catch, type ArgumentsHost, type ExceptionFilter } from '@nestjs/common';
|
||||
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
||||
|
||||
/** Maps a consumed/missing runtime approval to a stable HTTP authorization response. */
|
||||
@Catch(RuntimeApprovalDeniedError)
|
||||
export class RuntimeApprovalDeniedFilter implements ExceptionFilter {
|
||||
catch(_exception: RuntimeApprovalDeniedError, host: ArgumentsHost): void {
|
||||
const response = host.switchToHttp().getResponse<{
|
||||
status(code: number): { send(body: { statusCode: number; message: string }): void };
|
||||
}>();
|
||||
response.status(403).send({ statusCode: 403, message: 'Runtime termination approval denied' });
|
||||
}
|
||||
}
|
||||
@@ -64,13 +64,20 @@ export interface RuntimeTerminationAction {
|
||||
tenantId: string;
|
||||
channelId: string;
|
||||
correlationId: string;
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export interface RuntimeApprovalVerifier {
|
||||
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||
}
|
||||
|
||||
class RuntimeApprovalDeniedError extends Error {
|
||||
function configuredAgentName(): string {
|
||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!agentName) throw new RuntimeApprovalDeniedError();
|
||||
return agentName;
|
||||
}
|
||||
|
||||
export class RuntimeApprovalDeniedError extends Error {
|
||||
constructor() {
|
||||
super('Runtime termination approval denied');
|
||||
}
|
||||
@@ -261,6 +268,7 @@ export class RuntimeProviderService {
|
||||
tenantId: scope.tenantId,
|
||||
channelId: scope.channelId,
|
||||
correlationId: scope.correlationId,
|
||||
agentName: configuredAgentName(),
|
||||
});
|
||||
if (!approved) {
|
||||
throw new RuntimeApprovalDeniedError();
|
||||
@@ -293,7 +301,7 @@ export class RuntimeProviderService {
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const durationMs = Date.now() - startedAt;
|
||||
if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) {
|
||||
if (invocationStarted && !this.isAuthorizationDenied(error)) {
|
||||
await this.recordFailure(providerId, operation, scope, resourceId, durationMs);
|
||||
} else {
|
||||
await this.record(
|
||||
@@ -352,6 +360,17 @@ export class RuntimeProviderService {
|
||||
}
|
||||
}
|
||||
|
||||
private isAuthorizationDenied(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof RuntimeApprovalDeniedError ||
|
||||
error instanceof ForbiddenException ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'forbidden')
|
||||
);
|
||||
}
|
||||
|
||||
private provider(providerId: string): AgentRuntimeProvider {
|
||||
try {
|
||||
return this.registry.require(providerId);
|
||||
|
||||
10
apps/gateway/src/agent/tess-durable-session.dto.ts
Normal file
10
apps/gateway/src/agent/tess-durable-session.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||
|
||||
/** Server-side request for a replay-safe provider message. */
|
||||
export interface TessProviderOutboxDto {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
content: string;
|
||||
context: RuntimeProviderRequestContext;
|
||||
}
|
||||
418
apps/gateway/src/agent/tess-durable-session.repository.test.ts
Normal file
418
apps/gateway/src/agent/tess-durable-session.repository.test.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/db';
|
||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||
|
||||
const IDENTITY: DurableSessionIdentity = {
|
||||
agentName: 'Nova',
|
||||
sessionId: 'tess-pglite-session',
|
||||
tenantId: 'tenant-pglite',
|
||||
ownerId: 'tess-owner',
|
||||
providerId: 'fleet',
|
||||
runtimeSessionId: 'nova',
|
||||
};
|
||||
|
||||
describe('TessDurableSessionRepository', () => {
|
||||
let dataDir: string | undefined;
|
||||
let handle: DbHandle;
|
||||
let previousAuthSecret: string | undefined;
|
||||
|
||||
beforeAll(async (): Promise<void> => {
|
||||
previousAuthSecret = process.env['BETTER_AUTH_SECRET'];
|
||||
process.env['BETTER_AUTH_SECRET'] = 'tess-durable-state-test-sealing-key';
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'tess-durable-state-'));
|
||||
handle = createPgliteDb(dataDir);
|
||||
await runPgliteMigrations(handle);
|
||||
await seedOwner(handle);
|
||||
}, 30_000);
|
||||
|
||||
beforeEach(async (): Promise<void> => {
|
||||
await handle.db.execute(sql`DELETE FROM interaction_handoffs`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_checkpoints`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_inbox`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_outbox`);
|
||||
await handle.db.execute(sql`DELETE FROM interaction_sessions`);
|
||||
});
|
||||
|
||||
afterAll(async (): Promise<void> => {
|
||||
await handle.close();
|
||||
if (dataDir) rmSync(dataDir, { recursive: true, force: true });
|
||||
if (previousAuthSecret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||
else process.env['BETTER_AUTH_SECRET'] = previousAuthSecret;
|
||||
});
|
||||
|
||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||
const beforeRestart = new DurableSessionCoordinator(
|
||||
new TessDurableSessionRepository(handle.db),
|
||||
);
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-before-kill',
|
||||
correlationId: 'correlation-before-kill',
|
||||
content: 'resume after a kill',
|
||||
});
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-before-kill',
|
||||
correlationId: 'correlation-before-kill',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'one response only',
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-before-kill',
|
||||
cursor: 'cursor-before-kill',
|
||||
summary: 'restart-safe state',
|
||||
compactionEpoch: 1,
|
||||
});
|
||||
await beforeRestart.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-before-kill',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-before-kill',
|
||||
checkpointId: 'checkpoint-before-kill',
|
||||
status: 'pending',
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-after-handoff',
|
||||
cursor: 'cursor-after-handoff',
|
||||
summary: 'newer state cannot strand the portable handoff',
|
||||
compactionEpoch: 2,
|
||||
});
|
||||
|
||||
await handle.close();
|
||||
handle = createPgliteDb(dataDir!);
|
||||
|
||||
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||
const handled: string[] = [];
|
||||
const effects: string[] = [];
|
||||
|
||||
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
effects.push(entry.idempotencyKey);
|
||||
});
|
||||
await afterRestart.drainInbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
effects.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(recovered.identity).toEqual(IDENTITY);
|
||||
expect(recovered.checkpoint).toMatchObject({ checkpointId: 'checkpoint-after-handoff' });
|
||||
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-before-kill' }]);
|
||||
expect(resumedHandoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-before-kill' });
|
||||
expect(handled).toEqual(['inbox-before-kill']);
|
||||
expect(effects).toEqual(['outbox-before-kill']);
|
||||
}, 30_000);
|
||||
|
||||
it('redacts sensitive durable payloads before persistence', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'redacted-inbox',
|
||||
correlationId: 'correlation-redaction',
|
||||
content: 'api_key=super-secret-canary',
|
||||
});
|
||||
await coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'redacted-outbox',
|
||||
correlationId: 'correlation-redaction',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'email operator@example.test api_key=super-secret-canary',
|
||||
});
|
||||
await coordinator.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'redacted-checkpoint',
|
||||
cursor: 'bearer super-secret-canary',
|
||||
summary: 'email operator@example.test',
|
||||
compactionEpoch: 0,
|
||||
});
|
||||
|
||||
const snapshot = await coordinator.snapshot(IDENTITY.sessionId);
|
||||
const [persisted] = await handle.db
|
||||
.select({ content: interactionInbox.content })
|
||||
.from(interactionInbox)
|
||||
.where(eq(interactionInbox.idempotencyKey, 'redacted-inbox'));
|
||||
|
||||
expect(JSON.stringify(snapshot)).not.toContain('super-secret-canary');
|
||||
expect(JSON.stringify(snapshot)).not.toContain('operator@example.test');
|
||||
expect(persisted?.content).not.toContain('super-secret-canary');
|
||||
expect(persisted?.content).not.toContain('[REDACTED]');
|
||||
}, 30_000);
|
||||
|
||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||
delete process.env['BETTER_AUTH_SECRET'];
|
||||
try {
|
||||
await expect(
|
||||
coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'requires-idempotency-secret',
|
||||
correlationId: 'correlation-secret',
|
||||
content: 'sensitive payload',
|
||||
}),
|
||||
).rejects.toThrow(/required for durable idempotency digests/);
|
||||
} finally {
|
||||
if (secret === undefined) delete process.env['BETTER_AUTH_SECRET'];
|
||||
else process.env['BETTER_AUTH_SECRET'] = secret;
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-secret-conflict',
|
||||
cursor: 'api_key=secret-one',
|
||||
summary: 'bearer secret-one',
|
||||
compactionEpoch: 1,
|
||||
};
|
||||
await coordinator.checkpoint(input);
|
||||
|
||||
await expect(
|
||||
coordinator.checkpoint({
|
||||
...input,
|
||||
cursor: 'api_key=secret-two',
|
||||
summary: 'bearer secret-two',
|
||||
}),
|
||||
).rejects.toThrow(/checkpoint identity conflict/);
|
||||
|
||||
const [persisted] = await handle.db
|
||||
.select({
|
||||
digest: interactionCheckpoints.contentDigest,
|
||||
cursor: interactionCheckpoints.cursor,
|
||||
})
|
||||
.from(interactionCheckpoints)
|
||||
.where(eq(interactionCheckpoints.checkpointId, input.checkpointId));
|
||||
expect(persisted?.cursor).not.toContain('secret-one');
|
||||
expect(persisted?.digest).not.toBe(
|
||||
createHash('sha256')
|
||||
.update(JSON.stringify([input.cursor, input.summary]))
|
||||
.digest('hex'),
|
||||
);
|
||||
|
||||
await coordinator.checkpoint({
|
||||
...input,
|
||||
checkpointId: 'checkpoint-delimiter-conflict',
|
||||
cursor: 'a\u0000b',
|
||||
summary: 'c',
|
||||
});
|
||||
await expect(
|
||||
coordinator.checkpoint({
|
||||
...input,
|
||||
checkpointId: 'checkpoint-delimiter-conflict',
|
||||
cursor: 'a',
|
||||
summary: 'b\u0000c',
|
||||
}),
|
||||
).rejects.toThrow(/checkpoint identity conflict/);
|
||||
}, 30_000);
|
||||
|
||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
const inbox = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-secret-conflict',
|
||||
correlationId: 'correlation-inbox-secret',
|
||||
content: 'api_key=secret-one',
|
||||
};
|
||||
const outbox = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-secret-conflict',
|
||||
correlationId: 'correlation-outbox-secret',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'api_key=secret-one',
|
||||
};
|
||||
await coordinator.receive(inbox);
|
||||
await coordinator.enqueueOutbox(outbox);
|
||||
|
||||
await expect(coordinator.receive({ ...inbox, content: 'api_key=secret-two' })).rejects.toThrow(
|
||||
/idempotency conflict/,
|
||||
);
|
||||
await expect(
|
||||
coordinator.enqueueOutbox({ ...outbox, content: 'api_key=secret-two' }),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
}, 30_000);
|
||||
|
||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-conflict',
|
||||
correlationId: 'correlation-inbox',
|
||||
content: 'original inbox',
|
||||
});
|
||||
await coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'original outbox',
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'inbox-conflict',
|
||||
correlationId: 'forged-correlation',
|
||||
content: 'original inbox',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
await expect(
|
||||
coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'forged-channel',
|
||||
kind: 'provider.send',
|
||||
content: 'original outbox',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
}, 30_000);
|
||||
|
||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||
const repository = new TessDurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'live-effect',
|
||||
correlationId: 'correlation-live',
|
||||
content: 'must not duplicate',
|
||||
context: {
|
||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-live',
|
||||
},
|
||||
};
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await service.queueProviderSend(input);
|
||||
expect(await repository.claimOutbox(IDENTITY.sessionId)).toMatchObject({
|
||||
status: 'processing',
|
||||
});
|
||||
|
||||
await service.dispatchProviderOutbox(IDENTITY.sessionId, input);
|
||||
await expect(
|
||||
service.recoverProviderSession(IDENTITY.sessionId, {
|
||||
...input,
|
||||
context: {
|
||||
...input.context,
|
||||
actorScope: { userId: 'intruder', tenantId: 'tenant-pglite' },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/scope or correlation mismatch/);
|
||||
|
||||
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [{ idempotencyKey: 'live-effect', status: 'processing' }],
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||
const repository = new TessDurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||
const input = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'mismatch-effect',
|
||||
correlationId: 'correlation-expected',
|
||||
content: 'must remain pending',
|
||||
context: {
|
||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-expected',
|
||||
},
|
||||
};
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await service.queueProviderSend(input);
|
||||
await expect(
|
||||
service.dispatchProviderOutbox(IDENTITY.sessionId, {
|
||||
...input,
|
||||
correlationId: 'correlation-forged',
|
||||
context: { ...input.context, correlationId: 'correlation-forged' },
|
||||
}),
|
||||
).rejects.toThrow(/scope or correlation mismatch/);
|
||||
|
||||
expect(runtimeProviders.sendMessage).not.toHaveBeenCalled();
|
||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [{ idempotencyKey: 'mismatch-effect', status: 'pending' }],
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||
const repository = new TessDurableSessionRepository(handle.db);
|
||||
const coordinator = new DurableSessionCoordinator(repository);
|
||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||
const first = {
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'scoped-effect-one',
|
||||
correlationId: 'correlation-one',
|
||||
content: 'first result',
|
||||
context: {
|
||||
actorScope: { userId: IDENTITY.ownerId, tenantId: IDENTITY.tenantId },
|
||||
channelId: 'cli',
|
||||
correlationId: 'correlation-one',
|
||||
},
|
||||
};
|
||||
const second = {
|
||||
...first,
|
||||
idempotencyKey: 'scoped-effect-two',
|
||||
correlationId: 'correlation-two',
|
||||
content: 'second result',
|
||||
context: { ...first.context, correlationId: 'correlation-two' },
|
||||
};
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await service.queueProviderSend(first);
|
||||
await service.queueProviderSend(second);
|
||||
await service.dispatchProviderOutbox(IDENTITY.sessionId, first);
|
||||
|
||||
expect(runtimeProviders.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeProviders.sendMessage).toHaveBeenCalledWith(
|
||||
IDENTITY.providerId,
|
||||
IDENTITY.runtimeSessionId,
|
||||
{ content: 'first result', idempotencyKey: 'scoped-effect-one' },
|
||||
first.context,
|
||||
);
|
||||
expect(await coordinator.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [
|
||||
{ idempotencyKey: 'scoped-effect-one', status: 'delivered' },
|
||||
{ idempotencyKey: 'scoped-effect-two', status: 'pending' },
|
||||
],
|
||||
});
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
async function seedOwner(handle: DbHandle): Promise<void> {
|
||||
await handle.db.execute(sql`
|
||||
INSERT INTO users (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES ('tess-owner', 'Tess Owner', 'tess-owner@example.test', false, now(), now())
|
||||
`);
|
||||
}
|
||||
530
apps/gateway/src/agent/tess-durable-session.repository.ts
Normal file
530
apps/gateway/src/agent/tess-durable-session.repository.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import { createHash, createHmac } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
interactionCheckpoints,
|
||||
interactionHandoffs,
|
||||
interactionInbox,
|
||||
interactionOutbox,
|
||||
interactionSessions,
|
||||
type Db,
|
||||
} from '@mosaicstack/db';
|
||||
import { seal, unseal } from '@mosaicstack/auth';
|
||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||
import type {
|
||||
DurableCheckpoint,
|
||||
DurableCheckpointInput,
|
||||
DurableEnqueueResult,
|
||||
DurableHandoff,
|
||||
DurableHandoffInput,
|
||||
DurableInboxEntry,
|
||||
DurableInboxInput,
|
||||
DurableInboxStatus,
|
||||
DurableOutboxEntry,
|
||||
DurableOutboxInput,
|
||||
DurableOutboxStatus,
|
||||
DurableSessionIdentity,
|
||||
DurableSessionSnapshot,
|
||||
DurableSessionStore,
|
||||
} from '@mosaicstack/agent';
|
||||
import { DB } from '../database/database.module.js';
|
||||
|
||||
@Injectable()
|
||||
export class TessDurableSessionRepository implements DurableSessionStore {
|
||||
constructor(@Inject(DB) private readonly db: Db) {}
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
await this.db
|
||||
.insert(interactionSessions)
|
||||
.values({
|
||||
id: identity.sessionId,
|
||||
agentName: identity.agentName,
|
||||
tenantId: identity.tenantId,
|
||||
ownerId: identity.ownerId,
|
||||
providerId: identity.providerId,
|
||||
runtimeSessionId: identity.runtimeSessionId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const existing = await this.session(identity.sessionId);
|
||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||
}
|
||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||
// the conversation handle and owner scope remain immutable.
|
||||
if (
|
||||
existing.providerId !== identity.providerId ||
|
||||
existing.runtimeSessionId !== identity.runtimeSessionId
|
||||
) {
|
||||
await this.db
|
||||
.update(interactionSessions)
|
||||
.set({ providerId: identity.providerId, runtimeSessionId: identity.runtimeSessionId })
|
||||
.where(eq(interactionSessions.id, identity.sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
||||
const identity = await this.session(sessionId);
|
||||
if (!identity) return null;
|
||||
|
||||
const [inbox, outbox, checkpoints, handoffs] = await Promise.all([
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionInbox)
|
||||
.where(eq(interactionInbox.sessionId, sessionId))
|
||||
.orderBy(asc(interactionInbox.createdAt)),
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionOutbox)
|
||||
.where(eq(interactionOutbox.sessionId, sessionId))
|
||||
.orderBy(asc(interactionOutbox.createdAt)),
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionCheckpoints)
|
||||
.where(eq(interactionCheckpoints.sessionId, sessionId))
|
||||
.orderBy(
|
||||
desc(interactionCheckpoints.compactionEpoch),
|
||||
desc(interactionCheckpoints.createdAt),
|
||||
)
|
||||
.limit(1),
|
||||
this.db
|
||||
.select()
|
||||
.from(interactionHandoffs)
|
||||
.where(eq(interactionHandoffs.sessionId, sessionId))
|
||||
.orderBy(asc(interactionHandoffs.createdAt)),
|
||||
]);
|
||||
|
||||
const checkpoint = checkpoints[0];
|
||||
return {
|
||||
identity,
|
||||
inbox: inbox.map(toInbox),
|
||||
outbox: outbox.map(toOutbox),
|
||||
...(checkpoint ? { checkpoint: toCheckpoint(checkpoint) } : {}),
|
||||
handoffs: handoffs.map(toHandoff),
|
||||
};
|
||||
}
|
||||
|
||||
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||
const digest = contentDigest(input.content);
|
||||
const record: DurableInboxInput = {
|
||||
...input,
|
||||
content: redactSensitiveContent(input.content).content,
|
||||
};
|
||||
const inserted = await this.db
|
||||
.insert(interactionInbox)
|
||||
.values({
|
||||
...record,
|
||||
content: seal(record.content),
|
||||
contentDigest: digest,
|
||||
status: 'pending',
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ status: interactionInbox.status });
|
||||
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(interactionInbox)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionInbox.sessionId, input.sessionId),
|
||||
eq(interactionInbox.idempotencyKey, input.idempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
||||
const entry = toInbox(existing[0]);
|
||||
if (
|
||||
!sameInbox(entry, record) ||
|
||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||
) {
|
||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: entry.status };
|
||||
}
|
||||
|
||||
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const candidate = await this.db
|
||||
.select()
|
||||
.from(interactionInbox)
|
||||
.where(
|
||||
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'pending')),
|
||||
)
|
||||
.orderBy(asc(interactionInbox.createdAt))
|
||||
.limit(1);
|
||||
const entry = candidate[0];
|
||||
if (!entry) return null;
|
||||
const claimed = await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(interactionInbox.id, entry.id), eq(interactionInbox.status, 'pending')))
|
||||
.returning();
|
||||
if (claimed[0]) return toInbox(claimed[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'processed', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionInbox.sessionId, sessionId),
|
||||
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionInbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionInbox.sessionId, sessionId),
|
||||
eq(interactionInbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionInbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async enqueueOutbox(
|
||||
input: DurableOutboxInput,
|
||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||
const digest = contentDigest(input.content);
|
||||
const record: DurableOutboxInput = {
|
||||
...input,
|
||||
content: redactSensitiveContent(input.content).content,
|
||||
};
|
||||
const inserted = await this.db
|
||||
.insert(interactionOutbox)
|
||||
.values({
|
||||
...record,
|
||||
content: seal(record.content),
|
||||
contentDigest: digest,
|
||||
status: 'pending',
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ status: interactionOutbox.status });
|
||||
if (inserted[0]) return { accepted: true, status: inserted[0].status };
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(interactionOutbox)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, input.sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, input.idempotencyKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!existing[0])
|
||||
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
||||
const entry = toOutbox(existing[0]);
|
||||
if (
|
||||
!sameOutbox(entry, record) ||
|
||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||
) {
|
||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: entry.status };
|
||||
}
|
||||
|
||||
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const candidate = await this.db
|
||||
.select()
|
||||
.from(interactionOutbox)
|
||||
.where(
|
||||
and(eq(interactionOutbox.sessionId, sessionId), eq(interactionOutbox.status, 'pending')),
|
||||
)
|
||||
.orderBy(asc(interactionOutbox.createdAt))
|
||||
.limit(1);
|
||||
const entry = candidate[0];
|
||||
if (!entry) return null;
|
||||
const claimed = await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(and(eq(interactionOutbox.id, entry.id), eq(interactionOutbox.status, 'pending')))
|
||||
.returning();
|
||||
if (claimed[0]) return toOutbox(claimed[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async claimOutboxByKey(
|
||||
sessionId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<DurableOutboxEntry | null> {
|
||||
const claimed = await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'processing', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionOutbox.status, 'pending'),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return claimed[0] ? toOutbox(claimed[0]) : null;
|
||||
}
|
||||
|
||||
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'delivered', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionOutbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
await this.db
|
||||
.update(interactionOutbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(interactionOutbox.sessionId, sessionId),
|
||||
eq(interactionOutbox.idempotencyKey, idempotencyKey),
|
||||
eq(interactionOutbox.status, 'processing'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||
// Compute identity before redaction. The persisted digest is keyed so a database
|
||||
// reader cannot use it as an offline oracle for sensitive cursor/summary values.
|
||||
const digest = contentDigest(JSON.stringify([input.cursor, input.summary]));
|
||||
const checkpoint: DurableCheckpointInput = {
|
||||
...input,
|
||||
cursor: redactSensitiveContent(input.cursor).content,
|
||||
summary: redactSensitiveContent(input.summary).content,
|
||||
};
|
||||
const inserted = await this.db
|
||||
.insert(interactionCheckpoints)
|
||||
.values({
|
||||
...checkpoint,
|
||||
contentDigest: digest,
|
||||
cursor: seal(checkpoint.cursor),
|
||||
summary: seal(checkpoint.summary),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ checkpointId: interactionCheckpoints.checkpointId });
|
||||
if (inserted[0]) return;
|
||||
|
||||
const existing = await this.db
|
||||
.select()
|
||||
.from(interactionCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionCheckpoints.sessionId, input.sessionId),
|
||||
eq(interactionCheckpoints.checkpointId, input.checkpointId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
!existing[0] ||
|
||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||
) {
|
||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
||||
const checkpoints = await this.db
|
||||
.select()
|
||||
.from(interactionCheckpoints)
|
||||
.where(
|
||||
and(
|
||||
eq(interactionCheckpoints.sessionId, sessionId),
|
||||
eq(interactionCheckpoints.checkpointId, checkpointId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const checkpoint = checkpoints[0];
|
||||
return checkpoint ? toCheckpoint(checkpoint) : null;
|
||||
}
|
||||
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
}
|
||||
const inserted = await this.db
|
||||
.insert(interactionHandoffs)
|
||||
.values({ ...input })
|
||||
.onConflictDoNothing()
|
||||
.returning({ handoffId: interactionHandoffs.handoffId });
|
||||
if (inserted[0]) return;
|
||||
|
||||
const existing = await this.findHandoff(input.handoffId);
|
||||
if (!existing || !sameHandoff(existing, input)) {
|
||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
||||
const handoffs = await this.db
|
||||
.select()
|
||||
.from(interactionHandoffs)
|
||||
.where(eq(interactionHandoffs.handoffId, handoffId))
|
||||
.limit(1);
|
||||
const handoff = handoffs[0];
|
||||
return handoff ? toHandoff(handoff) : null;
|
||||
}
|
||||
|
||||
async requeueInFlight(sessionId: string): Promise<void> {
|
||||
// Inbox handlers are process-local work. A provider outbox claim may have
|
||||
// reached an external target before a crash, so it is deliberately not
|
||||
// replayed by generic recovery.
|
||||
await this.db
|
||||
.update(interactionInbox)
|
||||
.set({ status: 'pending', updatedAt: new Date() })
|
||||
.where(
|
||||
and(eq(interactionInbox.sessionId, sessionId), eq(interactionInbox.status, 'processing')),
|
||||
);
|
||||
}
|
||||
|
||||
private async session(sessionId: string): Promise<DurableSessionIdentity | null> {
|
||||
const sessions = await this.db
|
||||
.select()
|
||||
.from(interactionSessions)
|
||||
.where(eq(interactionSessions.id, sessionId))
|
||||
.limit(1);
|
||||
const session = sessions[0];
|
||||
return session
|
||||
? {
|
||||
agentName: session.agentName,
|
||||
sessionId: session.id,
|
||||
tenantId: session.tenantId,
|
||||
ownerId: session.ownerId,
|
||||
providerId: session.providerId,
|
||||
runtimeSessionId: session.runtimeSessionId,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
function contentDigest(content: string): string {
|
||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||
if (!secret) {
|
||||
throw new Error('BETTER_AUTH_SECRET is required for durable idempotency digests');
|
||||
}
|
||||
return `hmac:v1:${createHmac('sha256', secret).update(content).digest('hex')}`;
|
||||
}
|
||||
|
||||
function matchesContentDigest(stored: string, content: string): boolean {
|
||||
return (
|
||||
stored === contentDigest(content) ||
|
||||
stored === createHash('sha256').update(content).digest('hex')
|
||||
);
|
||||
}
|
||||
|
||||
function matchesCheckpointDigest(stored: string, digest: string): boolean {
|
||||
// Legacy rows predate any pre-redaction identity and cannot safely prove equality.
|
||||
// Reject rather than let redaction collapse distinct sensitive checkpoint payloads.
|
||||
return stored === digest;
|
||||
}
|
||||
|
||||
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
||||
return (
|
||||
left.agentName === right.agentName &&
|
||||
left.sessionId === right.sessionId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.ownerId === right.ownerId
|
||||
);
|
||||
}
|
||||
|
||||
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.channelId === right.channelId &&
|
||||
left.kind === right.kind &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.compactionEpoch === right.compactionEpoch
|
||||
);
|
||||
}
|
||||
|
||||
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.handoffId === right.handoffId &&
|
||||
left.destination === right.destination &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.status === right.status
|
||||
);
|
||||
}
|
||||
|
||||
function toInbox(row: typeof interactionInbox.$inferSelect): DurableInboxEntry {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
correlationId: row.correlationId,
|
||||
content: unseal(row.content),
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
function toOutbox(row: typeof interactionOutbox.$inferSelect): DurableOutboxEntry {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
idempotencyKey: row.idempotencyKey,
|
||||
correlationId: row.correlationId,
|
||||
channelId: row.channelId,
|
||||
kind: row.kind,
|
||||
content: unseal(row.content),
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
function toCheckpoint(row: typeof interactionCheckpoints.$inferSelect): DurableCheckpoint {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
checkpointId: row.checkpointId,
|
||||
cursor: unseal(row.cursor),
|
||||
summary: unseal(row.summary),
|
||||
compactionEpoch: row.compactionEpoch,
|
||||
};
|
||||
}
|
||||
|
||||
function toHandoff(row: typeof interactionHandoffs.$inferSelect): DurableHandoff {
|
||||
return {
|
||||
sessionId: row.sessionId,
|
||||
handoffId: row.handoffId,
|
||||
destination: row.destination,
|
||||
correlationId: row.correlationId,
|
||||
checkpointId: row.checkpointId,
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
123
apps/gateway/src/agent/tess-durable-session.service.ts
Normal file
123
apps/gateway/src/agent/tess-durable-session.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
||||
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||
import {
|
||||
RuntimeProviderService,
|
||||
type RuntimeProviderRequestContext,
|
||||
} from './runtime-provider-registry.service.js';
|
||||
|
||||
/**
|
||||
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TessDurableSessionService {
|
||||
private readonly coordinator: DurableSessionCoordinator;
|
||||
|
||||
constructor(
|
||||
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||
) {
|
||||
this.coordinator = new DurableSessionCoordinator(repository);
|
||||
}
|
||||
|
||||
/** Enroll a verified runtime session under the stable cross-surface conversation handle. */
|
||||
async enroll(
|
||||
identity: DurableSessionIdentity,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
identity.ownerId !== context.actorScope.userId ||
|
||||
identity.tenantId !== context.actorScope.tenantId
|
||||
) {
|
||||
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
|
||||
}
|
||||
await this.coordinator.create(identity);
|
||||
}
|
||||
|
||||
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
await this.coordinator.enqueueOutbox({
|
||||
sessionId: input.sessionId,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
correlationId: input.correlationId,
|
||||
channelId: input.context.channelId,
|
||||
kind: 'provider.send',
|
||||
content: input.content,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||
if (sessionId !== input.sessionId) {
|
||||
throw new ForbiddenException('Durable Tess outbox session mismatch');
|
||||
}
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
const pendingEntry = snapshot.outbox.find(
|
||||
(entry): boolean => entry.idempotencyKey === input.idempotencyKey,
|
||||
);
|
||||
if (!pendingEntry) return;
|
||||
// Validate immutable routing before claiming. A caller with a mismatched
|
||||
// correlation/channel must not strand a pending external side effect.
|
||||
this.assertOutboxScope(pendingEntry, input);
|
||||
await this.coordinator.dispatchOutboxEntry(
|
||||
sessionId,
|
||||
input.idempotencyKey,
|
||||
async (entry): Promise<void> => {
|
||||
this.assertOutboxScope(entry, input);
|
||||
await this.runtimeProviders.sendMessage(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
{ content: entry.content, idempotencyKey: entry.idempotencyKey },
|
||||
input.context,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Read durable identity/state only after deriving and checking the server-side actor scope. */
|
||||
async getSnapshot(sessionId: string, context: RuntimeProviderRequestContext) {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, {
|
||||
sessionId,
|
||||
content: '',
|
||||
idempotencyKey: 'read-only',
|
||||
correlationId: context.correlationId,
|
||||
context,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||
await this.coordinator.recover(sessionId);
|
||||
}
|
||||
|
||||
private assertOutboxScope(
|
||||
entry: { kind: string; correlationId: string; channelId: string },
|
||||
input: TessProviderOutboxDto,
|
||||
): void {
|
||||
if (
|
||||
entry.kind !== 'provider.send' ||
|
||||
entry.correlationId !== input.correlationId ||
|
||||
entry.channelId !== input.context.channelId
|
||||
) {
|
||||
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
||||
if (
|
||||
input.context.actorScope.userId !== ownerId ||
|
||||
input.context.actorScope.tenantId !== tenantId ||
|
||||
input.context.correlationId !== input.correlationId
|
||||
) {
|
||||
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
||||
}
|
||||
}
|
||||
}
|
||||
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
41
apps/gateway/src/agent/tools/memory-tools.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createMemoryTools } from './memory-tools.js';
|
||||
|
||||
describe('createMemoryTools operator retrieval binding', () => {
|
||||
const memory = {
|
||||
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
|
||||
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
|
||||
};
|
||||
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
|
||||
|
||||
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
|
||||
const plugin = {
|
||||
search: vi.fn(async () => []),
|
||||
capture: vi.fn(async () => ({ id: 'insight-1' })),
|
||||
};
|
||||
const tools = createMemoryTools(memory as never, null, 'owner-a', {
|
||||
plugin: plugin as never,
|
||||
scope,
|
||||
});
|
||||
|
||||
await tools
|
||||
.find((tool) => tool.name === 'memory_search')!
|
||||
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
|
||||
await tools
|
||||
.find((tool) => tool.name === 'memory_save_insight')!
|
||||
.execute(
|
||||
'call-2',
|
||||
{ content: 'secret', category: 'decision' },
|
||||
undefined,
|
||||
undefined,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
|
||||
expect(plugin.capture).toHaveBeenCalledWith(scope, {
|
||||
content: 'secret',
|
||||
source: 'agent',
|
||||
category: 'decision',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||
import type { Memory } from '@mosaicstack/memory';
|
||||
import type { EmbeddingProvider } from '@mosaicstack/memory';
|
||||
import type {
|
||||
EmbeddingProvider,
|
||||
Memory,
|
||||
OperatorMemoryPlugin,
|
||||
OperatorMemoryScope,
|
||||
} from '@mosaicstack/memory';
|
||||
|
||||
/**
|
||||
* Create memory tools bound to the session's authenticated userId.
|
||||
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
|
||||
export function createMemoryTools(
|
||||
memory: Memory,
|
||||
embeddingProvider: EmbeddingProvider | null,
|
||||
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
||||
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
||||
sessionUserId: string | undefined,
|
||||
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
||||
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
||||
): ToolDefinition[] {
|
||||
/** Return an error result when no session user is bound. */
|
||||
function noUserError() {
|
||||
@@ -46,6 +52,14 @@ export function createMemoryTools(
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
if (operatorMemory) {
|
||||
const results = await operatorMemory.plugin.search(operatorMemory.scope, query, limit ?? 5);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (!embeddingProvider) {
|
||||
return {
|
||||
content: [
|
||||
@@ -158,6 +172,18 @@ export function createMemoryTools(
|
||||
};
|
||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
||||
|
||||
if (operatorMemory) {
|
||||
const insight = await operatorMemory.plugin.capture(operatorMemory.scope, {
|
||||
content,
|
||||
source: 'agent',
|
||||
category: category ?? 'learning',
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify(insight, null, 2) }],
|
||||
details: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let embedding: number[] | null = null;
|
||||
if (embeddingProvider) {
|
||||
embedding = await embeddingProvider.embed(content);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Inject, Logger } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Inject, Logger, Optional } from '@nestjs/common';
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
@@ -13,6 +14,9 @@ import { Server, Socket } from 'socket.io';
|
||||
import type { AgentSessionEvent } from '@mariozechner/pi-coding-agent';
|
||||
import {
|
||||
verifyDiscordIngressEnvelope,
|
||||
parseDiscordInteractionBindings,
|
||||
resolveDiscordInteractionActorId,
|
||||
resolveDiscordInteractionBinding,
|
||||
type DiscordIngressEnvelope,
|
||||
type DiscordIngressPayload,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
@@ -28,6 +32,12 @@ import type {
|
||||
AbortPayload,
|
||||
} from '@mosaicstack/types';
|
||||
import { AgentService, type ConversationHistoryMessage } from '../agent/agent.service.js';
|
||||
import {
|
||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||
RuntimeProviderService,
|
||||
type RuntimeAuditSink,
|
||||
} from '../agent/runtime-provider-registry.service.js';
|
||||
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
|
||||
import { AUTH } from '../auth/auth.tokens.js';
|
||||
import {
|
||||
scopeFromUser,
|
||||
@@ -37,6 +47,7 @@ import {
|
||||
import { BRAIN } from '../brain/brain.tokens.js';
|
||||
import { CommandRegistryService } from '../commands/command-registry.service.js';
|
||||
import { CommandExecutorService } from '../commands/command-executor.service.js';
|
||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||
import { RoutingEngineService } from '../agent/routing/routing-engine.service.js';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { ChatSocketMessageDto } from './chat.dto.js';
|
||||
@@ -122,6 +133,18 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
@Inject(CommandRegistryService) private readonly commandRegistry: CommandRegistryService,
|
||||
@Inject(CommandExecutorService) private readonly commandExecutor: CommandExecutorService,
|
||||
@Inject(RoutingEngineService) private readonly routingEngine: RoutingEngineService,
|
||||
@Optional()
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly commandAuthorization: CommandAuthorizationService | null = null,
|
||||
@Optional()
|
||||
@Inject(RuntimeProviderService)
|
||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||
@Optional()
|
||||
@Inject(TessDurableSessionService)
|
||||
private readonly durableSessions: TessDurableSessionService | null = null,
|
||||
@Optional()
|
||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||
) {}
|
||||
|
||||
afterInit(): void {
|
||||
@@ -637,9 +660,185 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
* Creates it if absent — safe to call concurrently since a duplicate insert
|
||||
* would fail on the PK constraint and be caught here.
|
||||
*/
|
||||
@SubscribeMessage('discord:approve')
|
||||
async handleDiscordApproval(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||
): Promise<void> {
|
||||
if (!client.data.discordService) return;
|
||||
const ingress = this.resolveDiscordIngress(client, envelope, 'approve');
|
||||
const isApprovalCommand = /^\/approve\s*$/i.test(ingress?.content ?? '');
|
||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||
if (
|
||||
!ingress ||
|
||||
!isApprovalCommand ||
|
||||
!tenantId ||
|
||||
!this.commandAuthorization ||
|
||||
!this.durableSessions
|
||||
)
|
||||
return;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
ingress.guildId,
|
||||
ingress.channelId,
|
||||
ingress.userId,
|
||||
'approve',
|
||||
);
|
||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!actorId || !agentName || binding.instanceId !== agentName) {
|
||||
this.logger.warn(
|
||||
`Rejected Discord approval without a matching runtime agent from ${client.id}`,
|
||||
);
|
||||
client.emit('discord:approval', {
|
||||
correlationId: ingress.correlationId,
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let snapshot;
|
||||
try {
|
||||
snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, {
|
||||
actorScope: { userId: actorId, tenantId },
|
||||
channelId: ingress.channelId,
|
||||
correlationId: ingress.correlationId,
|
||||
});
|
||||
} catch {
|
||||
client.emit('discord:approval', {
|
||||
correlationId: ingress.correlationId,
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (snapshot.identity.agentName !== agentName) {
|
||||
client.emit('discord:approval', {
|
||||
correlationId: ingress.correlationId,
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const approval = await this.commandAuthorization.createRuntimeTerminationApproval({
|
||||
providerId: snapshot.identity.providerId,
|
||||
sessionId: snapshot.identity.runtimeSessionId,
|
||||
actorId,
|
||||
tenantId,
|
||||
channelId: ingress.channelId,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
),
|
||||
agentName,
|
||||
});
|
||||
if (!approval) {
|
||||
await this.runtimeAudit?.record({
|
||||
providerId: snapshot.identity.providerId,
|
||||
operation: 'session.terminate',
|
||||
outcome: 'denied',
|
||||
actorId,
|
||||
tenantId,
|
||||
channelId: ingress.channelId,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
),
|
||||
resourceId: snapshot.identity.runtimeSessionId,
|
||||
errorCode: 'policy_denied',
|
||||
});
|
||||
}
|
||||
client.emit('discord:approval', {
|
||||
correlationId: ingress.correlationId,
|
||||
success: approval !== null,
|
||||
approvalId: approval?.approvalId,
|
||||
expiresAt: approval?.expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeMessage('discord:stop')
|
||||
async handleDiscordStop(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() envelope: DiscordIngressEnvelope,
|
||||
): Promise<void> {
|
||||
if (!client.data.discordService) return;
|
||||
const ingress = this.resolveDiscordIngress(client, envelope, 'stop');
|
||||
const approvalRef = /^\/stop\s+([^\s]+)$/i.exec(ingress?.content ?? '')?.[1];
|
||||
const tenantId = process.env['DISCORD_SERVICE_TENANT_ID']?.trim();
|
||||
if (!ingress || !approvalRef || !tenantId || !this.runtimeRegistry || !this.durableSessions)
|
||||
return;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
ingress.guildId,
|
||||
ingress.channelId,
|
||||
ingress.userId,
|
||||
'stop',
|
||||
);
|
||||
const actorId = binding && resolveDiscordInteractionActorId(binding, ingress.userId);
|
||||
if (!actorId) return;
|
||||
|
||||
try {
|
||||
const context = {
|
||||
actorScope: { userId: actorId, tenantId },
|
||||
channelId: ingress.channelId,
|
||||
correlationId: ingress.correlationId,
|
||||
};
|
||||
const snapshot = await this.durableSessions.getSnapshot(ingress.conversationId, context);
|
||||
if (snapshot.identity.agentName !== binding.instanceId) throw new Error('agent mismatch');
|
||||
// RuntimeProviderService consumes the durable approval exactly once using the
|
||||
// provisioned approving-admin identity, never the Discord service account.
|
||||
await this.runtimeRegistry.terminate(
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
approvalRef,
|
||||
{
|
||||
...context,
|
||||
correlationId: this.discordRuntimeActionCorrelation(
|
||||
binding.instanceId,
|
||||
ingress,
|
||||
snapshot.identity.providerId,
|
||||
snapshot.identity.runtimeSessionId,
|
||||
),
|
||||
},
|
||||
);
|
||||
client.emit('discord:stop', { correlationId: ingress.correlationId, success: true });
|
||||
} catch {
|
||||
client.emit('discord:stop', { correlationId: ingress.correlationId, success: false });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Correlates the immutable termination target rather than either Discord message.
|
||||
* Approval and stop are distinct ingress events, but must consume the same seven-field action.
|
||||
*/
|
||||
private discordRuntimeActionCorrelation(
|
||||
instanceId: string,
|
||||
ingress: DiscordIngressPayload,
|
||||
providerId: string,
|
||||
sessionId: string,
|
||||
): string {
|
||||
const target = [
|
||||
instanceId,
|
||||
ingress.guildId,
|
||||
ingress.channelId,
|
||||
ingress.conversationId,
|
||||
providerId,
|
||||
sessionId,
|
||||
];
|
||||
return `discord-action:v1:${createHash('sha256').update(JSON.stringify(target)).digest('hex')}`;
|
||||
}
|
||||
|
||||
private resolveDiscordIngress(
|
||||
client: Socket,
|
||||
envelope: DiscordIngressEnvelope,
|
||||
operation: 'send' | 'approve' | 'stop' = 'send',
|
||||
): DiscordIngressPayload | null {
|
||||
const payload = verifyDiscordIngressEnvelope(
|
||||
envelope,
|
||||
@@ -654,6 +853,24 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
||||
this.logger.warn(`Rejected invalid Discord ingress envelope from ${client.id}`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
parseDiscordInteractionBindings(process.env['DISCORD_INTERACTION_BINDINGS']),
|
||||
payload.guildId,
|
||||
payload.channelId,
|
||||
payload.userId,
|
||||
operation,
|
||||
);
|
||||
if (!binding) {
|
||||
this.logger.warn(`Rejected unpaired Discord ingress from ${client.id}`);
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`Rejected Discord ingress without valid binding configuration from ${client.id}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (!this.discordReplayProtector.claim(payload.messageId)) {
|
||||
this.logger.warn(
|
||||
`Rejected replayed Discord message=${payload.messageId} correlation=${payload.correlationId}`,
|
||||
|
||||
@@ -12,8 +12,10 @@ const adminCommand: CommandDef = {
|
||||
};
|
||||
const payload: SlashCommandPayload = { command: 'gc', conversationId: 'conversation-1' };
|
||||
|
||||
function createService(role: string): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
function createService(
|
||||
role: string,
|
||||
entries: Map<string, string> = new Map<string, string>(),
|
||||
): CommandAuthorizationService {
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||
};
|
||||
@@ -58,4 +60,57 @@ describe('CommandAuthorizationService', () => {
|
||||
(await service.authorize(adminCommand, payload, 'member-1', 'forged-approval-id')).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('denies a malformed durable approval expiry instead of treating it as unexpired', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
providerId: 'fleet',
|
||||
sessionId: 'nova',
|
||||
actorId: 'admin-1',
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'discord:operator',
|
||||
correlationId: 'correlation-malformed-expiry',
|
||||
agentName: 'Nova',
|
||||
};
|
||||
const service = createService('admin', entries);
|
||||
const approval = await service.createRuntimeTerminationApproval(action);
|
||||
expect(approval).not.toBeNull();
|
||||
const key = `agent:Nova:command-approval:${approval!.approvalId}`;
|
||||
const stored = entries.get(key);
|
||||
expect(stored).toBeDefined();
|
||||
entries.set(key, JSON.stringify({ ...JSON.parse(stored!), expiresAt: 'not-a-date' }));
|
||||
|
||||
expect(await service.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('persists and consumes one exact runtime termination approval across a service restart', async (): Promise<void> => {
|
||||
const entries = new Map<string, string>();
|
||||
const action = {
|
||||
providerId: 'fleet',
|
||||
sessionId: 'nova',
|
||||
actorId: 'admin-1',
|
||||
tenantId: 'tenant-1',
|
||||
channelId: 'discord:operator',
|
||||
correlationId: 'correlation-1',
|
||||
agentName: 'Nova',
|
||||
};
|
||||
const beforeRestart = createService('admin', entries);
|
||||
const approval = await beforeRestart.createRuntimeTerminationApproval(action);
|
||||
|
||||
const afterRestart = createService('admin', entries);
|
||||
expect(
|
||||
await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, {
|
||||
...action,
|
||||
sessionId: 'forged-session',
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(await afterRestart.consumeRuntimeTerminationApproval(approval!.approvalId, action)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,24 @@ export interface CommandApproval {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** Exact immutable binding for a privileged runtime termination. */
|
||||
export interface RuntimeTerminationApprovalAction {
|
||||
providerId: string;
|
||||
sessionId: string;
|
||||
actorId: string;
|
||||
tenantId: string;
|
||||
channelId: string;
|
||||
correlationId: string;
|
||||
/** Provisioned roster identity; isolates approvals between interaction agents. */
|
||||
agentName: string;
|
||||
}
|
||||
|
||||
export interface RuntimeTerminationApproval extends RuntimeTerminationApprovalAction {
|
||||
approvalId: string;
|
||||
actionDigest: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface CommandAuthorizationResult {
|
||||
allowed: boolean;
|
||||
reason?: string;
|
||||
@@ -75,6 +93,57 @@ export class CommandAuthorizationService {
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses the same `interaction:command-approval:*` store and one-time deletion rule as
|
||||
* command approvals. This deliberately avoids a parallel approval database.
|
||||
*/
|
||||
async createRuntimeTerminationApproval(
|
||||
action: RuntimeTerminationApprovalAction,
|
||||
): Promise<RuntimeTerminationApproval | null> {
|
||||
if (!this.hasRuntimeTerminationAction(action)) return null;
|
||||
const role = await this.resolveRole(action.actorId);
|
||||
if (role !== 'admin') return null;
|
||||
|
||||
const approval: RuntimeTerminationApproval = {
|
||||
approvalId: randomUUID(),
|
||||
actionDigest: this.runtimeActionDigest(action),
|
||||
...action,
|
||||
expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(),
|
||||
};
|
||||
await this.redis.set(
|
||||
this.runtimeKey(action.agentName, approval.approvalId),
|
||||
JSON.stringify(approval),
|
||||
'EX',
|
||||
'300',
|
||||
);
|
||||
return approval;
|
||||
}
|
||||
|
||||
async consumeRuntimeTerminationApproval(
|
||||
approvalId: string,
|
||||
action: RuntimeTerminationApprovalAction,
|
||||
): Promise<boolean> {
|
||||
const encoded = await this.redis.get(this.runtimeKey(action.agentName, approvalId));
|
||||
if (!encoded) return false;
|
||||
let approval: unknown;
|
||||
try {
|
||||
approval = JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isRuntimeTerminationApproval(approval) ||
|
||||
approval.actionDigest !== this.runtimeActionDigest(action) ||
|
||||
approval.actorId !== action.actorId ||
|
||||
approval.tenantId !== action.tenantId ||
|
||||
!this.isUnexpired(approval.expiresAt)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if ((await this.resolveRole(approval.actorId)) !== 'admin') return false;
|
||||
return (await this.redis.del(this.runtimeKey(action.agentName, approvalId))) === 1;
|
||||
}
|
||||
|
||||
private async resolveRole(actorId: string): Promise<CommandRole | null> {
|
||||
const [user] = await this.db
|
||||
.select({ role: usersTable.role })
|
||||
@@ -98,12 +167,17 @@ export class CommandAuthorizationService {
|
||||
const key = this.key(approvalId);
|
||||
const encoded = await this.redis.get(key);
|
||||
if (!encoded) return false;
|
||||
const parsed: unknown = JSON.parse(encoded);
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(encoded);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!this.isApproval(parsed) ||
|
||||
!this.isCommandApproval(parsed) ||
|
||||
parsed.actorId !== actorId ||
|
||||
parsed.actionDigest !== actionDigest ||
|
||||
Date.parse(parsed.expiresAt) <= Date.now()
|
||||
!this.isUnexpired(parsed.expiresAt)
|
||||
)
|
||||
return false;
|
||||
return (await this.redis.del(key)) === 1;
|
||||
@@ -121,18 +195,74 @@ export class CommandAuthorizationService {
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private isApproval(value: unknown): value is CommandApproval {
|
||||
private hasRuntimeTerminationAction(action: RuntimeTerminationApprovalAction): boolean {
|
||||
return [
|
||||
action.providerId,
|
||||
action.sessionId,
|
||||
action.actorId,
|
||||
action.tenantId,
|
||||
action.channelId,
|
||||
action.correlationId,
|
||||
action.agentName,
|
||||
].every((value: string): boolean => value.trim().length > 0);
|
||||
}
|
||||
|
||||
private runtimeActionDigest(action: RuntimeTerminationApprovalAction): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
providerId: action.providerId,
|
||||
sessionId: action.sessionId,
|
||||
actorId: action.actorId,
|
||||
tenantId: action.tenantId,
|
||||
channelId: action.channelId,
|
||||
correlationId: action.correlationId,
|
||||
agentName: action.agentName,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
private isUnexpired(expiresAt: unknown): expiresAt is string {
|
||||
if (typeof expiresAt !== 'string') return false;
|
||||
const expiresAtMs = Date.parse(expiresAt);
|
||||
return Number.isFinite(expiresAtMs) && expiresAtMs > Date.now();
|
||||
}
|
||||
|
||||
private isCommandApproval(value: unknown): value is CommandApproval {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'approvalId' in value &&
|
||||
'actionDigest' in value &&
|
||||
'actorId' in value &&
|
||||
'expiresAt' in value &&
|
||||
'command' in value
|
||||
);
|
||||
}
|
||||
|
||||
private isRuntimeTerminationApproval(value: unknown): value is RuntimeTerminationApproval {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'approvalId' in value &&
|
||||
'actionDigest' in value &&
|
||||
'actorId' in value &&
|
||||
'tenantId' in value &&
|
||||
'providerId' in value &&
|
||||
'sessionId' in value &&
|
||||
'channelId' in value &&
|
||||
'correlationId' in value &&
|
||||
'agentName' in value &&
|
||||
'expiresAt' in value
|
||||
);
|
||||
}
|
||||
|
||||
private key(approvalId: string): string {
|
||||
return `tess:command-approval:${approvalId}`;
|
||||
return `interaction:command-approval:${approvalId}`;
|
||||
}
|
||||
|
||||
private runtimeKey(agentName: string, approvalId: string): string {
|
||||
return `agent:${encodeURIComponent(agentName)}:command-approval:${approvalId}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ReloadModule } from '../reload/reload.module.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
import { CommandExecutorService } from './command-executor.service.js';
|
||||
import { CommandRegistryService } from './command-registry.service.js';
|
||||
import { CommandRuntimeApprovalVerifier } from './runtime-approval-verifier.js';
|
||||
import { COMMANDS_REDIS } from './commands.tokens.js';
|
||||
|
||||
const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
@@ -26,9 +27,15 @@ const COMMANDS_QUEUE_HANDLE = 'COMMANDS_QUEUE_HANDLE';
|
||||
},
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [
|
||||
CommandRegistryService,
|
||||
CommandAuthorizationService,
|
||||
CommandRuntimeApprovalVerifier,
|
||||
CommandExecutorService,
|
||||
],
|
||||
exports: [CommandRegistryService, CommandExecutorService],
|
||||
})
|
||||
export class CommandsModule implements OnApplicationShutdown {
|
||||
constructor(@Inject(COMMANDS_QUEUE_HANDLE) private readonly handle: QueueHandle) {}
|
||||
|
||||
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
23
apps/gateway/src/commands/runtime-approval-verifier.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
RuntimeApprovalVerifier,
|
||||
RuntimeTerminationAction,
|
||||
} from '../agent/runtime-provider-registry.service.js';
|
||||
import { CommandAuthorizationService } from './command-authorization.service.js';
|
||||
|
||||
/**
|
||||
* Adapter from the provider registry's exact termination action to the shared,
|
||||
* Redis-backed `interaction:command-approval:*` store. It has no separate approval
|
||||
* persistence or replay semantics.
|
||||
*/
|
||||
@Injectable()
|
||||
export class CommandRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||
constructor(
|
||||
@Inject(CommandAuthorizationService)
|
||||
private readonly authorization: CommandAuthorizationService,
|
||||
) {}
|
||||
|
||||
async consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean> {
|
||||
return this.authorization.consumeRuntimeTerminationApproval(approvalRef, action);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
|
||||
import { CoordService } from './coord.service.js';
|
||||
import { CoordController } from './coord.controller.js';
|
||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
||||
import {
|
||||
MOS_COORDINATION_CONFIG,
|
||||
MOS_COORDINATION_PORT,
|
||||
MosCoordinationService,
|
||||
} from './mos-coordination.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [CoordService],
|
||||
controllers: [CoordController],
|
||||
exports: [CoordService],
|
||||
providers: [
|
||||
CoordService,
|
||||
{
|
||||
provide: MOS_COORDINATION_PORT,
|
||||
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
|
||||
},
|
||||
{
|
||||
provide: MOS_COORDINATION_CONFIG,
|
||||
useFactory: () => ({
|
||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
||||
}),
|
||||
},
|
||||
MosCoordinationService,
|
||||
],
|
||||
controllers: [CoordController, MosCoordinationController],
|
||||
exports: [CoordService, MosCoordinationService],
|
||||
})
|
||||
export class CoordModule {}
|
||||
|
||||
36
apps/gateway/src/coord/mos-coordination.controller.test.ts
Normal file
36
apps/gateway/src/coord/mos-coordination.controller.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MosCoordinationController } from './mos-coordination.controller.js';
|
||||
|
||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||
|
||||
describe('MosCoordinationController', () => {
|
||||
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||
const coordination = {
|
||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
const controller = new MosCoordinationController(coordination as never);
|
||||
|
||||
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
|
||||
|
||||
expect(coordination.handoff).toHaveBeenCalledWith(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement' },
|
||||
expect.objectContaining({
|
||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||
channelId: 'cli',
|
||||
correlationId: 'corr-1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('requires a correlation header before invoking the coordination service', async () => {
|
||||
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
|
||||
const controller = new MosCoordinationController(coordination as never);
|
||||
|
||||
await expect(
|
||||
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),
|
||||
).rejects.toThrow('X-Correlation-Id is required');
|
||||
expect(coordination.handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
73
apps/gateway/src/coord/mos-coordination.controller.ts
Normal file
73
apps/gateway/src/coord/mos-coordination.controller.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Headers,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '../auth/auth.guard.js';
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import type {
|
||||
MosCoordinationObservationDto,
|
||||
MosCoordinationResponseDto,
|
||||
MosCoordinationResultDto,
|
||||
CreateMosHandoffDto,
|
||||
} from './mos-coordination.dto.js';
|
||||
import { MosCoordinationService } from './mos-coordination.service.js';
|
||||
|
||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only Mos contract. */
|
||||
@Controller('api/coord/mos')
|
||||
@UseGuards(AuthGuard)
|
||||
export class MosCoordinationController {
|
||||
constructor(
|
||||
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
|
||||
) {}
|
||||
|
||||
@Post('handoff')
|
||||
async handoff(
|
||||
@Body() request: CreateMosHandoffDto,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<MosCoordinationResponseDto> {
|
||||
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
|
||||
}
|
||||
|
||||
@Get(':handoffId/observe')
|
||||
async observe(
|
||||
@Param('handoffId') handoffId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<MosCoordinationObservationDto> {
|
||||
return {
|
||||
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':handoffId/result')
|
||||
async result(
|
||||
@Param('handoffId') handoffId: string,
|
||||
@CurrentUser() user: AuthenticatedUserLike,
|
||||
@Headers('x-correlation-id') correlationId?: string,
|
||||
): Promise<MosCoordinationResultDto> {
|
||||
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
|
||||
}
|
||||
|
||||
private context(
|
||||
user: AuthenticatedUserLike,
|
||||
correlationId?: string,
|
||||
): RuntimeProviderRequestContext {
|
||||
const requestCorrelationId = correlationId?.trim();
|
||||
if (!requestCorrelationId) throw new ForbiddenException('X-Correlation-Id is required');
|
||||
return {
|
||||
actorScope: scopeFromUser(user),
|
||||
channelId: 'cli',
|
||||
correlationId: requestCorrelationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
apps/gateway/src/coord/mos-coordination.dto.ts
Normal file
25
apps/gateway/src/coord/mos-coordination.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
CoordinationObservation,
|
||||
CoordinationResult,
|
||||
MosHandoffReceipt,
|
||||
} from '@mosaicstack/coord';
|
||||
|
||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
||||
export interface CreateMosHandoffDto {
|
||||
idempotencyKey: string;
|
||||
summary: string;
|
||||
context?: string;
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
export interface MosCoordinationResponseDto {
|
||||
receipt: MosHandoffReceipt;
|
||||
}
|
||||
|
||||
export interface MosCoordinationObservationDto {
|
||||
observation: CoordinationObservation;
|
||||
}
|
||||
|
||||
export interface MosCoordinationResultDto {
|
||||
result: CoordinationResult;
|
||||
}
|
||||
217
apps/gateway/src/coord/mos-coordination.service.test.ts
Normal file
217
apps/gateway/src/coord/mos-coordination.service.test.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
InMemoryMosCoordinationPort,
|
||||
type MosCoordinationPort,
|
||||
type MosHandoff,
|
||||
} from '@mosaicstack/coord';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import {
|
||||
MosCoordinationService,
|
||||
type MosCoordinationConfig,
|
||||
type MosCoordinationGatewayError,
|
||||
} from './mos-coordination.service.js';
|
||||
|
||||
const context: RuntimeProviderRequestContext = {
|
||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
||||
channelId: 'cli',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const config: MosCoordinationConfig = {
|
||||
interactionAgentId: 'Nova',
|
||||
orchestrationAgentId: 'Conductor',
|
||||
};
|
||||
|
||||
function service(
|
||||
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
|
||||
options: {
|
||||
config?: MosCoordinationConfig;
|
||||
handoffIdFactory?: () => string;
|
||||
} = {},
|
||||
): MosCoordinationService {
|
||||
return new MosCoordinationService(
|
||||
port,
|
||||
options.config ?? config,
|
||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
||||
);
|
||||
}
|
||||
|
||||
describe('MosCoordinationService authority boundary', (): void => {
|
||||
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const coordination = service(adapter);
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
handoffId: 'handoff-1',
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'queued',
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
|
||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||
|
||||
const followUpContext = { ...context, correlationId: 'corr-2' };
|
||||
await expect(coordination.observe('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
});
|
||||
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
summary: 'Merged by Mos',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter, {
|
||||
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'unconfigured_requester',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter, {
|
||||
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).rejects.toThrow('Interaction and orchestration identities must differ');
|
||||
expect(handoff).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const observe = vi.spyOn(adapter, 'observe');
|
||||
const result = vi.spyOn(adapter, 'result');
|
||||
const coordination = service(adapter);
|
||||
await coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
);
|
||||
|
||||
const otherTenant = {
|
||||
...context,
|
||||
actorScope: { ...context.actorScope, tenantId: 'tenant-b' },
|
||||
};
|
||||
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||
code: 'cross_tenant_forbidden',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
||||
code: 'cross_tenant_forbidden',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
expect(observe).not.toHaveBeenCalled();
|
||||
expect(result).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scopes idempotency by actor and joins concurrent retries without duplicate delivery', async (): Promise<void> => {
|
||||
let handoffSequence = 0;
|
||||
let release: (() => void) | undefined;
|
||||
const delivered = new Promise<void>((resolve: () => void): void => {
|
||||
release = resolve;
|
||||
});
|
||||
const adapter: MosCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: MosHandoff) => {
|
||||
await delivered;
|
||||
return {
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: handoff.targetAgentId,
|
||||
status: 'queued' as const,
|
||||
correlationId: handoff.scope.correlationId,
|
||||
};
|
||||
}),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
const coordination = service(adapter, {
|
||||
handoffIdFactory: (): string => `handoff-${++handoffSequence}`,
|
||||
});
|
||||
const request = { idempotencyKey: 'request-1', summary: 'Implement the requested feature' };
|
||||
|
||||
const first = coordination.handoff(request, context);
|
||||
const retry = coordination.handoff(request, context);
|
||||
expect(adapter.handoff).toHaveBeenCalledTimes(1);
|
||||
release?.();
|
||||
await expect(Promise.all([first, retry])).resolves.toEqual([
|
||||
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||
expect.objectContaining({ handoffId: 'handoff-1' }),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
coordination.handoff(request, {
|
||||
...context,
|
||||
actorScope: { ...context.actorScope, userId: 'operator-2' },
|
||||
}),
|
||||
).resolves.toMatchObject({ handoffId: 'handoff-2' });
|
||||
expect(adapter.handoff).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const handoff = vi.spyOn(adapter, 'handoff');
|
||||
const coordination = service(adapter);
|
||||
await coordination.handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
);
|
||||
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'handoff_conflict',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
await expect(
|
||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<MosCoordinationGatewayError>);
|
||||
expect(handoff).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
||||
const adapter: MosCoordinationPort = {
|
||||
handoff: vi.fn(async (handoff: MosHandoff) => ({
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: 'Unexpected',
|
||||
status: 'accepted' as const,
|
||||
correlationId: handoff.scope.correlationId,
|
||||
})),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service(adapter).handoff(
|
||||
{ idempotencyKey: 'request-1', summary: 'Implement the requested feature' },
|
||||
context,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'target_drift' });
|
||||
});
|
||||
});
|
||||
300
apps/gateway/src/coord/mos-coordination.service.ts
Normal file
300
apps/gateway/src/coord/mos-coordination.service.ts
Normal file
@@ -0,0 +1,300 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
MosCoordinationClient,
|
||||
type CoordinationObservation,
|
||||
type CoordinationResult,
|
||||
type CoordinationScope,
|
||||
type MosCoordinationIdentity,
|
||||
type MosCoordinationPort,
|
||||
type MosHandoffReceipt,
|
||||
} from '@mosaicstack/coord';
|
||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
||||
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
|
||||
|
||||
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
|
||||
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
|
||||
|
||||
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
|
||||
const MAX_TRACKED_HANDOFFS = 1_000;
|
||||
const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
|
||||
const MAX_SUMMARY_LENGTH = 2_048;
|
||||
const MAX_CONTEXT_LENGTH = 8_192;
|
||||
const MAX_MISSION_ID_LENGTH = 128;
|
||||
|
||||
export interface MosCoordinationConfig {
|
||||
interactionAgentId?: string;
|
||||
orchestrationAgentId?: string;
|
||||
}
|
||||
|
||||
interface HandoffOwner {
|
||||
actorId: string;
|
||||
tenantId: string;
|
||||
requesterAgentId: string;
|
||||
correlationId: string;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface NormalizedMosHandoffRequest {
|
||||
idempotencyKey: string;
|
||||
summary: string;
|
||||
context?: string;
|
||||
missionId?: string;
|
||||
}
|
||||
|
||||
interface TrackedHandoff {
|
||||
request: NormalizedMosHandoffRequest;
|
||||
receipt: Promise<MosHandoffReceipt>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway authority boundary for the interaction agent. It derives requester,
|
||||
* actor, and tenant from trusted server configuration and authentication; no
|
||||
* channel request can name a target or gain Mos-owned orchestration verbs.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MosCoordinationService {
|
||||
private readonly owners = new Map<string, HandoffOwner>();
|
||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
||||
|
||||
constructor(
|
||||
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
|
||||
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {}
|
||||
|
||||
async handoff(
|
||||
request: CreateMosHandoffDto,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<MosHandoffReceipt> {
|
||||
this.pruneExpiredTracking();
|
||||
const normalized = this.normalizeRequest(request);
|
||||
const scope = this.scope(context);
|
||||
const idempotencyKey = this.idempotencyKey(normalized.idempotencyKey, scope);
|
||||
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
|
||||
if (existing !== undefined) {
|
||||
if (!sameRequest(existing.request, normalized)) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
'handoff_conflict',
|
||||
'Mos handoff idempotency key is already bound to different immutable input',
|
||||
);
|
||||
}
|
||||
return existing.receipt;
|
||||
}
|
||||
|
||||
const pending = this.deliverHandoff(this.handoffIdFactory(), normalized, scope);
|
||||
const tracked: TrackedHandoff = {
|
||||
request: normalized,
|
||||
receipt: pending,
|
||||
expiresAt: this.expiresAt(),
|
||||
};
|
||||
this.handoffsByIdempotencyKey.set(idempotencyKey, tracked);
|
||||
this.enforceTrackingLimit(this.handoffsByIdempotencyKey);
|
||||
try {
|
||||
return await pending;
|
||||
} catch (error: unknown) {
|
||||
if (this.handoffsByIdempotencyKey.get(idempotencyKey) === tracked) {
|
||||
this.handoffsByIdempotencyKey.delete(idempotencyKey);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async observe(
|
||||
handoffId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<CoordinationObservation> {
|
||||
this.pruneExpiredTracking();
|
||||
const scope = this.scope(context);
|
||||
const owner = this.ownerFor(handoffId, scope);
|
||||
return this.client().observe(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||
}
|
||||
|
||||
async result(
|
||||
handoffId: string,
|
||||
context: RuntimeProviderRequestContext,
|
||||
): Promise<CoordinationResult> {
|
||||
this.pruneExpiredTracking();
|
||||
const scope = this.scope(context);
|
||||
const owner = this.ownerFor(handoffId, scope);
|
||||
return this.client().result(handoffId, { ...scope, correlationId: owner.correlationId });
|
||||
}
|
||||
|
||||
private async deliverHandoff(
|
||||
handoffId: string,
|
||||
request: NormalizedMosHandoffRequest,
|
||||
scope: CoordinationScope,
|
||||
): Promise<MosHandoffReceipt> {
|
||||
const receipt = await this.client((): string => handoffId).handoff(request, scope);
|
||||
const owner: HandoffOwner = {
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
requesterAgentId: scope.requesterAgentId,
|
||||
correlationId: scope.correlationId,
|
||||
expiresAt: this.expiresAt(),
|
||||
};
|
||||
const existing = this.owners.get(receipt.handoffId);
|
||||
if (existing !== undefined && !sameOwner(existing, owner)) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
'handoff_conflict',
|
||||
'Mos handoff ID is already bound to a different authenticated scope',
|
||||
);
|
||||
}
|
||||
this.owners.set(receipt.handoffId, owner);
|
||||
this.enforceTrackingLimit(this.owners);
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private client(handoffIdFactory?: () => string): MosCoordinationClient {
|
||||
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
||||
}
|
||||
|
||||
private identity(): MosCoordinationIdentity {
|
||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
||||
if (!interactionAgentId) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
'unconfigured_requester',
|
||||
'Interaction agent identity is not configured',
|
||||
);
|
||||
}
|
||||
if (!orchestrationAgentId) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
'unconfigured_target',
|
||||
'Orchestration agent identity is not configured',
|
||||
);
|
||||
}
|
||||
return { interactionAgentId, orchestrationAgentId };
|
||||
}
|
||||
|
||||
private scope(context: RuntimeProviderRequestContext): CoordinationScope {
|
||||
const identity = this.identity();
|
||||
return Object.freeze({
|
||||
actorId: context.actorScope.userId,
|
||||
tenantId: context.actorScope.tenantId,
|
||||
correlationId: context.correlationId,
|
||||
requesterAgentId: identity.interactionAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
|
||||
if (typeof request !== 'object' || request === null) {
|
||||
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
|
||||
}
|
||||
const idempotencyKey = this.requiredString(
|
||||
request.idempotencyKey,
|
||||
'idempotency key',
|
||||
MAX_IDEMPOTENCY_KEY_LENGTH,
|
||||
);
|
||||
const summary = this.requiredString(request.summary, 'summary', MAX_SUMMARY_LENGTH);
|
||||
const context = this.optionalString(request.context, 'context', MAX_CONTEXT_LENGTH);
|
||||
const missionId = this.optionalString(request.missionId, 'mission ID', MAX_MISSION_ID_LENGTH);
|
||||
return Object.freeze({
|
||||
idempotencyKey,
|
||||
summary,
|
||||
...(context === undefined ? {} : { context }),
|
||||
...(missionId === undefined ? {} : { missionId }),
|
||||
});
|
||||
}
|
||||
|
||||
private idempotencyKey(requestKey: string, scope: CoordinationScope): string {
|
||||
return `${scope.tenantId}\u0000${scope.actorId}\u0000${scope.requesterAgentId}\u0000${requestKey}`;
|
||||
}
|
||||
|
||||
private requiredString(value: unknown, field: string, maximumLength: number): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new MosCoordinationGatewayError(
|
||||
'invalid_request',
|
||||
`Mos handoff ${field} must be a string`,
|
||||
);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
||||
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private optionalString(value: unknown, field: string, maximumLength: number): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return this.requiredString(value, field, maximumLength);
|
||||
}
|
||||
|
||||
private expiresAt(): number {
|
||||
return Date.now() + HANDOFF_TRACKING_TTL_MS;
|
||||
}
|
||||
|
||||
private pruneExpiredTracking(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, tracked] of this.handoffsByIdempotencyKey) {
|
||||
if (tracked.expiresAt <= now) this.handoffsByIdempotencyKey.delete(key);
|
||||
}
|
||||
for (const [key, owner] of this.owners) {
|
||||
if (owner.expiresAt <= now) this.owners.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private enforceTrackingLimit<T>(entries: Map<string, T>): void {
|
||||
while (entries.size > MAX_TRACKED_HANDOFFS) {
|
||||
const oldest = entries.keys().next().value;
|
||||
if (typeof oldest !== 'string') return;
|
||||
entries.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
|
||||
const owner = this.owners.get(handoffId);
|
||||
if (owner === undefined) {
|
||||
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
|
||||
}
|
||||
if (
|
||||
owner.tenantId !== scope.tenantId ||
|
||||
owner.actorId !== scope.actorId ||
|
||||
owner.requesterAgentId !== scope.requesterAgentId
|
||||
) {
|
||||
throw new MosCoordinationGatewayError(
|
||||
'cross_tenant_forbidden',
|
||||
'Mos handoff is outside the authenticated scope',
|
||||
);
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
}
|
||||
|
||||
export type MosCoordinationGatewayErrorCode =
|
||||
| 'cross_tenant_forbidden'
|
||||
| 'handoff_conflict'
|
||||
| 'invalid_request'
|
||||
| 'not_found'
|
||||
| 'unconfigured_requester'
|
||||
| 'unconfigured_target';
|
||||
|
||||
function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
|
||||
return (
|
||||
left.actorId === right.actorId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.requesterAgentId === right.requesterAgentId
|
||||
);
|
||||
}
|
||||
|
||||
function sameRequest(
|
||||
left: NormalizedMosHandoffRequest,
|
||||
right: NormalizedMosHandoffRequest,
|
||||
): boolean {
|
||||
return (
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.summary === right.summary &&
|
||||
left.context === right.context &&
|
||||
left.missionId === right.missionId
|
||||
);
|
||||
}
|
||||
|
||||
export class MosCoordinationGatewayError extends Error {
|
||||
constructor(
|
||||
readonly code: MosCoordinationGatewayErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = MosCoordinationGatewayError.name;
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ describe('SessionGCService', () => {
|
||||
} as never;
|
||||
const payload = { command: 'gc', conversationId: 'owned' };
|
||||
const approval = await authorization.createApproval(command, payload, 'admin-1');
|
||||
const approvalKey = `tess:command-approval:${approval!.approvalId}`;
|
||||
const approvalKey = `interaction:command-approval:${approval!.approvalId}`;
|
||||
const gc = new SessionGCService(redis as never, mockLogService as unknown as LogService);
|
||||
|
||||
await gc.collect('owned');
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
createMemory,
|
||||
type Memory,
|
||||
createMemoryAdapter,
|
||||
createOperatorMemoryPlugin,
|
||||
type MemoryAdapter,
|
||||
type MemoryConfig,
|
||||
type OperatorMemoryPlugin,
|
||||
} from '@mosaicstack/memory';
|
||||
import type { Db } from '@mosaicstack/db';
|
||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||
@@ -14,6 +16,9 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
|
||||
import { MEMORY } from './memory.tokens.js';
|
||||
import { MemoryController } from './memory.controller.js';
|
||||
import { EmbeddingService } from './embedding.service.js';
|
||||
import { redactSensitiveContent } from '@mosaicstack/log';
|
||||
|
||||
export const OPERATOR_MEMORY_PLUGIN = 'OPERATOR_MEMORY_PLUGIN';
|
||||
|
||||
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
||||
|
||||
@@ -38,9 +43,24 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
|
||||
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
||||
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
||||
},
|
||||
{
|
||||
provide: OPERATOR_MEMORY_PLUGIN,
|
||||
useFactory: (adapter: MemoryAdapter): OperatorMemoryPlugin | null => {
|
||||
const instanceId = process.env['MOSAIC_OPERATOR_MEMORY_INSTANCE_ID']?.trim();
|
||||
const namespace = process.env['MOSAIC_OPERATOR_MEMORY_NAMESPACE']?.trim();
|
||||
if (!instanceId || !namespace) return null;
|
||||
return createOperatorMemoryPlugin({
|
||||
adapter,
|
||||
instanceId,
|
||||
namespace,
|
||||
redact: (content) => redactSensitiveContent(content).content,
|
||||
});
|
||||
},
|
||||
inject: [MEMORY_ADAPTER],
|
||||
},
|
||||
EmbeddingService,
|
||||
],
|
||||
controllers: [MemoryController],
|
||||
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
|
||||
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
|
||||
})
|
||||
export class MemoryModule {}
|
||||
|
||||
@@ -1,13 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createDiscordIngressEnvelope,
|
||||
verifyDiscordIngressEnvelope,
|
||||
DiscordPlugin,
|
||||
type DiscordIngressPayload,
|
||||
parseDiscordInteractionBindings,
|
||||
resolveDiscordInteractionActorId,
|
||||
resolveDiscordInteractionBinding,
|
||||
} from '@mosaicstack/discord-plugin';
|
||||
import { RuntimeProviderService } from '../agent/runtime-provider-registry.service.js';
|
||||
import { ChatGateway } from '../chat/chat.gateway.js';
|
||||
import { CommandAuthorizationService } from '../commands/command-authorization.service.js';
|
||||
import { validateDiscordServiceToken } from '../chat/chat.gateway-auth.js';
|
||||
import { DiscordReplayProtector } from './discord-replay-protector.js';
|
||||
|
||||
const SERVICE_TOKEN = 'test-service-token';
|
||||
const ENV_KEYS = [
|
||||
'DISCORD_SERVICE_TOKEN',
|
||||
'DISCORD_SERVICE_USER_ID',
|
||||
'DISCORD_SERVICE_TENANT_ID',
|
||||
'DISCORD_INTERACTION_BINDINGS',
|
||||
'DISCORD_ALLOWED_GUILD_IDS',
|
||||
'DISCORD_ALLOWED_CHANNEL_IDS',
|
||||
'DISCORD_ALLOWED_USER_IDS',
|
||||
'MOSAIC_AGENT_NAME',
|
||||
] as const;
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
|
||||
function configureDiscordEnv(role: 'admin' | 'member' = 'admin'): void {
|
||||
for (const key of ENV_KEYS) savedEnv.set(key, process.env[key]);
|
||||
process.env['DISCORD_SERVICE_TOKEN'] = SERVICE_TOKEN;
|
||||
process.env['DISCORD_SERVICE_USER_ID'] = 'discord-service';
|
||||
process.env['DISCORD_SERVICE_TENANT_ID'] = 'tenant-discord';
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||
process.env['DISCORD_ALLOWED_GUILD_IDS'] = 'guild-001';
|
||||
process.env['DISCORD_ALLOWED_CHANNEL_IDS'] = 'channel-001';
|
||||
process.env['DISCORD_ALLOWED_USER_IDS'] = 'user-001';
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: {
|
||||
'user-001': {
|
||||
role: role === 'admin' ? 'admin' : 'operator',
|
||||
mosaicUserId: 'mosaic-admin-001',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
afterEach((): void => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = savedEnv.get(key);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
savedEnv.clear();
|
||||
});
|
||||
|
||||
function commandAuthorization(role: 'admin' | 'member'): CommandAuthorizationService {
|
||||
const entries = new Map<string, string>();
|
||||
const db = {
|
||||
select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ role }] }) }) }),
|
||||
};
|
||||
const redis = {
|
||||
get: async (key: string) => entries.get(key) ?? null,
|
||||
set: async (key: string, value: string) => entries.set(key, value),
|
||||
del: async (key: string) => Number(entries.delete(key)),
|
||||
};
|
||||
return new CommandAuthorizationService(db as never, redis);
|
||||
}
|
||||
|
||||
function discordGateway(role: 'admin' | 'member'): {
|
||||
gateway: ChatGateway;
|
||||
client: { data: { discordService: boolean }; emit: ReturnType<typeof vi.fn> };
|
||||
consumedActions: Array<{ actorId: string; correlationId: string }>;
|
||||
durable: { getSnapshot: ReturnType<typeof vi.fn> };
|
||||
audit: { record: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
const authorization = commandAuthorization(role);
|
||||
const consumedActions: Array<{ actorId: string; correlationId: string }> = [];
|
||||
const durable = {
|
||||
getSnapshot: vi.fn().mockResolvedValue({
|
||||
identity: { agentName: 'Nova', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
}),
|
||||
};
|
||||
const audit = { record: vi.fn().mockResolvedValue(undefined) };
|
||||
const runtimeRegistry = new RuntimeProviderService(
|
||||
{
|
||||
require: () => ({
|
||||
capabilities: async () => ({ supported: ['session.terminate'] }),
|
||||
terminate: async () => undefined,
|
||||
}),
|
||||
} as never,
|
||||
{ record: async () => undefined } as never,
|
||||
{
|
||||
consume: async (approvalId, action) => {
|
||||
consumedActions.push({ actorId: action.actorId, correlationId: action.correlationId });
|
||||
return authorization.consumeRuntimeTerminationApproval(approvalId, action);
|
||||
},
|
||||
},
|
||||
);
|
||||
return {
|
||||
gateway: new ChatGateway(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
authorization,
|
||||
runtimeRegistry,
|
||||
durable as never,
|
||||
audit as never,
|
||||
),
|
||||
client: { data: { discordService: true }, emit: vi.fn() },
|
||||
consumedActions,
|
||||
durable,
|
||||
audit,
|
||||
};
|
||||
}
|
||||
|
||||
function ingressEnvelope(
|
||||
content: string,
|
||||
messageId: string,
|
||||
overrides: Partial<DiscordIngressPayload> = {},
|
||||
): ReturnType<typeof createDiscordIngressEnvelope> {
|
||||
return createDiscordIngressEnvelope(
|
||||
createPayload({ content, messageId, ...overrides }),
|
||||
SERVICE_TOKEN,
|
||||
);
|
||||
}
|
||||
|
||||
function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordIngressPayload {
|
||||
return {
|
||||
@@ -23,6 +148,42 @@ function createPayload(overrides: Partial<DiscordIngressPayload> = {}): DiscordI
|
||||
}
|
||||
|
||||
describe('Discord ingress security', () => {
|
||||
it('keeps legacy role-only bindings valid while withholding privileged actor identity', () => {
|
||||
const [binding] = parseDiscordInteractionBindings(
|
||||
JSON.stringify([
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': 'admin' },
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
resolveDiscordInteractionBinding([binding!], 'guild-001', 'channel-001', 'user-001', 'send'),
|
||||
).toEqual(binding);
|
||||
expect(resolveDiscordInteractionActorId(binding!, 'user-001')).toBeNull();
|
||||
});
|
||||
|
||||
it('binds a differently named configured interaction instance without code changes', () => {
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
[
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||
},
|
||||
],
|
||||
'guild-001',
|
||||
'channel-001',
|
||||
'user-001',
|
||||
'send',
|
||||
);
|
||||
|
||||
expect(binding?.instanceId).toBe('Nova');
|
||||
});
|
||||
|
||||
it('accepts only the configured Discord service identity', () => {
|
||||
expect(validateDiscordServiceToken(SERVICE_TOKEN, SERVICE_TOKEN)).toBe(true);
|
||||
expect(validateDiscordServiceToken('wrong-service-token', SERVICE_TOKEN)).toBe(false);
|
||||
@@ -86,4 +247,197 @@ describe('Discord ingress security', () => {
|
||||
expect(replayProtector.claim('discord-message-003')).toBe(true);
|
||||
expect(replayProtector.size).toBe(2);
|
||||
});
|
||||
|
||||
it('consumes the exact target once when approval and stop are separate Discord messages', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client, consumedActions } = discordGateway('admin');
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'approve-message', {
|
||||
correlationId: 'approval-ingress-correlation',
|
||||
}),
|
||||
);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
)?.[1] as {
|
||||
approvalId: string;
|
||||
success: boolean;
|
||||
};
|
||||
expect(approval.success).toBe(true);
|
||||
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'stop-message', {
|
||||
correlationId: 'stop-ingress-correlation',
|
||||
}),
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:stop', {
|
||||
correlationId: 'stop-ingress-correlation',
|
||||
success: true,
|
||||
});
|
||||
expect(consumedActions).toEqual([
|
||||
{
|
||||
actorId: 'mosaic-admin-001',
|
||||
correlationId: expect.stringMatching(/^discord-action:v1:/),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('audits a Discord mint-side authorization denial', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client, audit } = discordGateway('member');
|
||||
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'denied-approve'),
|
||||
);
|
||||
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||
correlationId: 'correlation-001',
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
expect(audit.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outcome: 'denied',
|
||||
operation: 'session.terminate',
|
||||
errorCode: 'policy_denied',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'binding',
|
||||
() => {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'Other';
|
||||
},
|
||||
],
|
||||
[
|
||||
'durable session',
|
||||
(durable: { getSnapshot: ReturnType<typeof vi.fn> }) => {
|
||||
durable.getSnapshot.mockResolvedValueOnce({
|
||||
identity: { agentName: 'Other', providerId: 'fleet', runtimeSessionId: 'runtime-1' },
|
||||
});
|
||||
},
|
||||
],
|
||||
])(
|
||||
'rejects approval when the %s targets a different runtime agent',
|
||||
async (_source, configure) => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client, durable } = discordGateway('admin');
|
||||
configure(durable);
|
||||
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'mismatched-agent-approve'),
|
||||
);
|
||||
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||
correlationId: 'correlation-001',
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects unpaired and non-admin Discord users for approval and stop', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client } = discordGateway('member');
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'member-approve'),
|
||||
);
|
||||
expect(client.emit).toHaveBeenCalledWith('discord:approval', {
|
||||
correlationId: 'correlation-001',
|
||||
success: false,
|
||||
approvalId: undefined,
|
||||
expiresAt: undefined,
|
||||
});
|
||||
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'] = JSON.stringify([]);
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope('/stop forged', 'unpaired-stop'),
|
||||
);
|
||||
expect(client.emit).not.toHaveBeenCalledWith('discord:stop', expect.anything());
|
||||
});
|
||||
|
||||
it('rejects replaying a Discord-created termination approval', async () => {
|
||||
configureDiscordEnv();
|
||||
const { gateway, client } = discordGateway('admin');
|
||||
await gateway.handleDiscordApproval(
|
||||
client as never,
|
||||
ingressEnvelope('/approve', 'replay-approve', {
|
||||
correlationId: 'replay-approval-correlation',
|
||||
}),
|
||||
);
|
||||
const approval = client.emit.mock.calls.find(
|
||||
([event]) => event === 'discord:approval',
|
||||
)?.[1] as {
|
||||
approvalId: string;
|
||||
};
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-one', {
|
||||
correlationId: 'replay-stop-correlation-one',
|
||||
}),
|
||||
);
|
||||
await gateway.handleDiscordStop(
|
||||
client as never,
|
||||
ingressEnvelope(`/stop ${approval.approvalId}`, 'replay-stop-two', {
|
||||
correlationId: 'replay-stop-correlation-two',
|
||||
}),
|
||||
);
|
||||
const stopResults = client.emit.mock.calls.filter(([event]) => event === 'discord:stop');
|
||||
expect(stopResults.map(([, result]) => (result as { success: boolean }).success)).toEqual([
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts a thread message through its allowed bound parent channel', () => {
|
||||
const emitted = vi.fn();
|
||||
const plugin = new DiscordPlugin({
|
||||
token: 'unused',
|
||||
gatewayUrl: 'http://unused',
|
||||
serviceToken: SERVICE_TOKEN,
|
||||
allowedGuildIds: ['guild-001'],
|
||||
allowedChannelIds: ['channel-001'],
|
||||
allowedUserIds: ['user-001'],
|
||||
interactionBindings: [
|
||||
{
|
||||
instanceId: 'Nova',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'channel-001',
|
||||
pairedUsers: { 'user-001': { role: 'operator', mosaicUserId: 'mosaic-operator-001' } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const internals = plugin as unknown as {
|
||||
client: { user: { id: string } };
|
||||
socket: { connected: boolean; emit: ReturnType<typeof vi.fn> };
|
||||
handleDiscordMessage(message: unknown): void;
|
||||
};
|
||||
internals.client = { user: { id: 'bot-001' } };
|
||||
internals.socket = { connected: true, emit: emitted };
|
||||
internals.handleDiscordMessage({
|
||||
id: 'thread-message',
|
||||
guildId: 'guild-001',
|
||||
channelId: 'thread-001',
|
||||
author: { id: 'user-001', bot: false },
|
||||
mentions: { has: () => true },
|
||||
content: '<@bot-001> hello from thread',
|
||||
channel: { parentId: 'channel-001' },
|
||||
attachments: new Map(),
|
||||
});
|
||||
|
||||
const [, envelope] = emitted.mock.calls[0] as [
|
||||
string,
|
||||
ReturnType<typeof createDiscordIngressEnvelope>,
|
||||
];
|
||||
expect(verifyDiscordIngressEnvelope(envelope, SERVICE_TOKEN)?.channelId).toBe('channel-001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { DiscordPlugin } from '@mosaicstack/discord-plugin';
|
||||
import { DiscordPlugin, parseDiscordInteractionBindings } from '@mosaicstack/discord-plugin';
|
||||
import { TelegramPlugin } from '@mosaicstack/telegram-plugin';
|
||||
import { PluginService } from './plugin.service.js';
|
||||
import type { IChannelPlugin } from './plugin.interface.js';
|
||||
@@ -85,6 +85,9 @@ function createPluginRegistry(): IChannelPlugin[] {
|
||||
allowedGuildIds: requiredDiscordAllowlist('DISCORD_ALLOWED_GUILD_IDS'),
|
||||
allowedChannelIds: requiredDiscordAllowlist('DISCORD_ALLOWED_CHANNEL_IDS'),
|
||||
allowedUserIds: requiredDiscordAllowlist('DISCORD_ALLOWED_USER_IDS'),
|
||||
interactionBindings: parseDiscordInteractionBindings(
|
||||
process.env['DISCORD_INTERACTION_BINDINGS'],
|
||||
),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -56,3 +56,11 @@
|
||||
**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.
|
||||
|
||||
## 2026-07-13 — M3 cross-surface delivery
|
||||
|
||||
**Branch:** `feat/tess-m3-integration` from `main` at `84d884b9`.
|
||||
|
||||
**Delivered:** Stable Discord `conversationId` enrollment after a visible provider/runtime session is known; idempotent provider-session rebinding that preserves agent/tenant/owner scope; Discord approval/stop target resolution through the durable snapshot; SSE runtime streaming after CLI attach; denial/audit parity including provider authorization denials and HTTP 403 approval-denial mapping.
|
||||
|
||||
**Evidence:** Gateway targeted suite: 37 tests passed; Mosaic CLI interaction test passed; agent durable-session test passed; gateway and CLI typechecks passed; changed-file format and whitespace checks passed. Codex security review found no confident vulnerability. Code review identified a Fastify exception-response mismatch and two UX/acknowledgement issues; all were corrected before the final validation run.
|
||||
|
||||
40
docs/scratchpads/tess-m2-001-pi-service.md
Normal file
40
docs/scratchpads/tess-m2-001-pi-service.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# TESS-M2-001 — Pi Interaction Service
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a generic rostered/systemd Pi operator-interaction service in
|
||||
`packages/mosaic/framework`.
|
||||
- Pin the service to `openai/gpt-5.6-sol`, high reasoning, and the
|
||||
`operator-interaction` tool policy.
|
||||
- Keep identity as provisioning data; the product name appears only in the
|
||||
committed example roster.
|
||||
|
||||
## Security and Configuration Invariants
|
||||
|
||||
1. The chosen display/roster name is supplied as data and must exactly match the
|
||||
generic systemd instance.
|
||||
2. The service fails before launch if runtime, model, reasoning, or tool policy
|
||||
differs from the pinned policy.
|
||||
3. Effective-policy output includes only name, runtime, model, reasoning, and
|
||||
tool policy; it does not inspect or output credential variables.
|
||||
4. The default example is replaceable without a source change; the TDD suite
|
||||
provisions `Nova` from the same profile.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `src/fleet/tess-service-profile.test.ts` proves a `Nova` provisioning path,
|
||||
roster parser/env serialization, effective-policy output, fail-fast drift
|
||||
rejection, and absence of the product name from generic source/profile.
|
||||
- `test-fleet-units.sh` validates the generic interaction systemd unit requires
|
||||
per-agent config and invokes fail-fast startup validation.
|
||||
- `test-start-agent-session.sh` proves the tool-policy value is exported into
|
||||
the Pi pane; `compose-contract.spec.ts` proves it becomes an explicit
|
||||
runtime contract block.
|
||||
- Fresh-worktree dependency install plus root `pnpm typecheck`, `pnpm lint`,
|
||||
`pnpm format:check`, and `pnpm test` passed; package/full fleet suites passed.
|
||||
- Independent Codex code and security reviews passed with no remaining findings.
|
||||
|
||||
## Delivery Notes
|
||||
|
||||
- Branch starts from fresh `origin/main` at `86a50138`.
|
||||
- PR targets `main` and references issue `#708`.
|
||||
63
docs/scratchpads/tess-m2-002-durable-state.md
Normal file
63
docs/scratchpads/tess-m2-002-durable-state.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# TESS-M2-002 — Durable Tess State
|
||||
|
||||
- **Issue:** #708
|
||||
- **Task:** `TESS-M2-002` / `TESS-STA-001`, `TESS-SEC-007..008`
|
||||
- **Branch:** `feat/tess-durable-state`
|
||||
- **Base:** fresh `origin/main` at `e3b5113be21e51d015fa1ae54572929b2a4acd9f`
|
||||
- **Budget assumption:** 38K task estimate; no explicit cap. Use focused TDD plus workspace validation.
|
||||
|
||||
## Objective
|
||||
|
||||
Persist a Tess session's immutable identity, inbox/outbox idempotency state, checkpoints,
|
||||
handoffs, and approval bindings so a new service instance can recover it after a process
|
||||
restart or context compaction without replaying a completed message or applied side effect.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Write recovery/idempotency tests first in `packages/agent/src/tess-durable-session.test.ts`.
|
||||
2. Add transport-neutral durable-state contracts/state machine in `packages/agent`.
|
||||
3. Add canonical PostgreSQL schema/migration and a gateway Drizzle repository adapter.
|
||||
4. Wire gateway service/module and reuse `tess:command-approval:*` durable approval semantics
|
||||
for exact, actor/tenant/action-bound approval consumption.
|
||||
5. Test PGlite restart recovery with separate service instances sharing the same durable DB.
|
||||
6. Document the recovery/compaction operation and update Tess architecture evidence.
|
||||
7. Run focused, cold-cache, workspace, migration, review, commit, push, and open PR to `main`.
|
||||
|
||||
## Required Evidence
|
||||
|
||||
| Requirement | Primary evidence |
|
||||
| --- | --- |
|
||||
| Restart recovery | Test creates a second coordinator over unchanged durable store after simulated process death. |
|
||||
| No duplicate side effects | Duplicate ingress and post-restart dispatch assert one handler/effect invocation. |
|
||||
| Compaction survival | Checkpoint/handoff/reconstructed state preserve the same session identity and pending records. |
|
||||
| Durable approvals | Existing `tess:command-approval` record is consumed only once and survives a new authorization service instance. |
|
||||
| Handoff | Stored handoff is portable and reconstructed without live process state. |
|
||||
|
||||
## Progress
|
||||
|
||||
- Intake complete: PRD AC-TESS-06, threat TM-07/TM-08, and verification matrix reviewed.
|
||||
- Affected surfaces: `packages/agent`, `apps/gateway`, `packages/db`; auth/authorization and DB migration tests required.
|
||||
- TDD is required (security authorization and critical state mutation).
|
||||
|
||||
## Risks
|
||||
|
||||
- An external provider action cannot be atomically committed with the database. The outbox
|
||||
gives the receiver a stable idempotency key; generic recovery never replays an ambiguous
|
||||
`processing` effect, and completed effects are never redispatched.
|
||||
- PostgreSQL is canonical; PGlite is the local/restart test implementation.
|
||||
|
||||
## Verification
|
||||
|
||||
- TDD red: `pnpm --filter @mosaicstack/agent test src/tess-durable-session.test.ts`
|
||||
initially failed because the durable-state module did not exist.
|
||||
- Focused green: 7 agent state-machine tests; 6 PGlite repository tests (including
|
||||
close/reopen recovery and encrypted-at-rest redaction); 5 durable-approval tests; DB migration tests.
|
||||
- Full cold-cache green: `pnpm turbo run typecheck lint test --force` completed
|
||||
88 tasks with zero cache hits; `pnpm format:check` and `git diff --check` passed.
|
||||
- Fresh worktree dependency install passed with
|
||||
`pnpm install --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store`.
|
||||
The default pnpm store path was inaccessible to this harness, so the explicit
|
||||
user-owned store path was required.
|
||||
- Codex review identified plaintext durable payload risk; resolved by AES-256-GCM sealing
|
||||
after redaction, with an at-rest ciphertext assertion in the PGlite suite.
|
||||
- Pending final clean review, commit, and PR.
|
||||
46
docs/scratchpads/tess-m4-001-mos-coordination.md
Normal file
46
docs/scratchpads/tess-m4-001-mos-coordination.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# TESS-M4-001 — Mos Coordination
|
||||
|
||||
- **Issue/task:** #710 / TESS-M4-001
|
||||
- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b`
|
||||
- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only.
|
||||
|
||||
## Objective
|
||||
|
||||
Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport.
|
||||
2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors.
|
||||
3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority.
|
||||
4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial.
|
||||
5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle.
|
||||
|
||||
## Design checkpoint — 2026-07-12
|
||||
|
||||
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
|
||||
|
||||
## Progress checkpoint — 2026-07-13
|
||||
|
||||
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
||||
- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
|
||||
- Added contract and gateway boundary tests for configurable identities, native round-trip, unconfigured requester, self-delegation, target drift, and cross-tenant observe/result denial before adapter invocation.
|
||||
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaicstack/coord test` — PASS (16 tests after authority/idempotency remediation).
|
||||
- `pnpm --filter @mosaicstack/coord build` — PASS.
|
||||
- `pnpm --filter @mosaicstack/gateway test -- mos-coordination.service.test.ts` — PASS (7 tests after authority/idempotency remediation).
|
||||
- Standalone gateway typecheck initially reported missing built workspace packages after fresh worktree setup; root validation builds the workspace graph and passed.
|
||||
- `TURBO_FORCE=true pnpm typecheck` — PASS (42 tasks, 0 cached).
|
||||
- `TURBO_FORCE=true pnpm lint` — PASS (23 tasks, 0 cached after one import-type remediation).
|
||||
- `TURBO_FORCE=true pnpm format:check` — PASS.
|
||||
- `TURBO_FORCE=true pnpm test` — PASS (42 tasks, 0 cached; expected existing integration skips only).
|
||||
|
||||
## Review checkpoint
|
||||
|
||||
- Codex code review found idempotency keys needed actor scope and concurrent retries needed an in-flight reservation; both were remediated with regression coverage.
|
||||
- Codex security review found whitespace-equivalent self-delegation was accepted by the exported client; identities are now normalized before invariant checks, with regression coverage.
|
||||
- Re-review added immutable payload comparison for idempotency reuse, runtime string/size validation, bounded TTL/capacity tracking for gateway and native adapter state, and fresh-correlation follow-up reads; targeted tests pass (16 coord / 7 gateway).
|
||||
- Final Codex security review found no issues. PR #735 was opened from commit `7936e15d`; Woodpecker pipeline #1752 is green.
|
||||
27
docs/scratchpads/tess-m4-003-operator-plugins.md
Normal file
27
docs/scratchpads/tess-m4-003-operator-plugins.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# TESS-M4-003 — Operator Plugin Foundations
|
||||
|
||||
- **Task:** TESS-M4-003 / TESS-MEM-001
|
||||
- **Branch/base:** `feat/tess-operator-plugins` rebased on `origin/main` `76325ca3`
|
||||
- **Scope:** first leaf-package memory/retrieval slice only; no durable inbox ownership, gateway integration, or Mosaic catalog implementation.
|
||||
|
||||
## Handoff
|
||||
|
||||
Coder4's uncommitted implementation was preserved first in commit `5b99c821` before review. The completion pass corrected the contract so namespace is injected configuration rather than caller-selected scope data, storage keys include tenant/owner/session via collision-safe tuple encoding, and malformed runtime scope values fail closed.
|
||||
|
||||
## Delivered boundary
|
||||
|
||||
- `OperatorMemoryPlugin` exposes `capture`, `search`, `recent`, `stats`, and `startupContext` through `MemoryAdapter` only.
|
||||
- Scope is server-derived `{tenantId, ownerId, sessionId}`; adapter and namespace are configuration, not operation input.
|
||||
- Capture redacts before persistence and records configured instance/namespace/source provenance.
|
||||
- Wildcard retrieval is documented at the `MemoryAdapter` boundary and implemented by the keyword adapter.
|
||||
- Startup context uses a bounded 64-result candidate window, then prioritizes project and flat-file provenance before slicing the configured output limit.
|
||||
- No `Tess` identity is hardcoded in storage keys or defaults; tests use configured `Nova`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @mosaicstack/memory test` — PASS (32 tests)
|
||||
- `pnpm --filter @mosaicstack/memory typecheck` — PASS
|
||||
- `pnpm --filter @mosaicstack/memory lint` — PASS
|
||||
- `pnpm --filter @mosaicstack/memory build` — PASS
|
||||
- Codex code review — APPROVE after remediation
|
||||
- Codex security review — no findings after runtime scope-validation remediation
|
||||
@@ -41,7 +41,7 @@ Every call receives an immutable, server-derived actor/tenant/channel scope and
|
||||
|
||||
`@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.
|
||||
Termination is fail-closed: a runtime approval verifier consumes a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. The verifier reuses the Redis-backed `interaction:command-approval:*` store and its expiry/delete-on-consume semantics; it has no parallel approval store. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
|
||||
|
||||
## Authority Model
|
||||
|
||||
@@ -52,11 +52,49 @@ Termination is fail-closed: a runtime approval verifier must consume a one-time,
|
||||
| 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 |
|
||||
|
||||
### Mos Coordination Boundary
|
||||
|
||||
`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort`
|
||||
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||
correlation, and interaction-agent identity from authenticated context plus
|
||||
trusted configuration; callers never provide an orchestration target. It
|
||||
rejects unconfigured identities, self-delegation, target/correlation drift, and
|
||||
cross-tenant handoff reads before an adapter call. No dispatch, assignment,
|
||||
review, merge, or cancellation API exists at this boundary.
|
||||
|
||||
M4 uses a deterministic native in-process queue adapter to prove the handoff →
|
||||
observe → result flow without coupling the contract to tmux. A fleet/tmux
|
||||
adapter is deferred to the M5 live-deployment seam and must implement the same
|
||||
port.
|
||||
|
||||
## 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.
|
||||
Valkey holds the existing short-lived, one-time command-approval records; PostgreSQL is canonical for durable session bindings, checkpoints, inbox/outbox, and idempotency. Pi session files are replay sources, not cross-agent truth.
|
||||
|
||||
### M2 Durable Recovery
|
||||
|
||||
`@mosaicstack/agent` owns a transport-neutral state machine and `apps/gateway` provides its
|
||||
PostgreSQL adapter. `interaction_sessions` holds immutable identity; inbox/outbox records use a
|
||||
per-session unique idempotency key and transition `pending → processing → processed|delivered`.
|
||||
Checkpoints are immutable history scoped by session and checkpoint ID: the latest checkpoint
|
||||
supports compaction recovery, while a handoff always resolves the exact checkpoint it references.
|
||||
Recovery requeues only interrupted inbox work; an ambiguous `processing` outbox record is preserved
|
||||
until separately authorized reconciliation can establish its external delivery state.
|
||||
|
||||
Provider sends travel through the existing `RuntimeProviderService` with the persisted outbox
|
||||
idempotency key. A normal dispatch claims exactly one outbox record and verifies its stored
|
||||
correlation and channel against the server-derived request scope; it never requeues or drains
|
||||
another live record. Inbox/outbox payloads and checkpoint cursor/summary pass through the existing
|
||||
secret/PII redactor and AES-256-GCM sealing before persistence; decryption occurs only in the
|
||||
scoped gateway repository path, and runtime audit remains metadata-only.
|
||||
|
||||
An external effect cannot share a database transaction. If a process dies after an effect begins
|
||||
but before its terminal outbox transition, automatic recovery does not replay that ambiguous claim.
|
||||
It remains `processing` until separately authorized reconciliation can establish delivery state;
|
||||
completed effects are never redispatched. Operators can therefore restart the gateway/Pi service,
|
||||
reconstruct the session, and resume pending inbox work without relying on process-local state.
|
||||
|
||||
## Transport Strategy
|
||||
|
||||
@@ -81,3 +119,5 @@ The provider advertises list, tree, read-only attach, send, and terminate. List/
|
||||
## 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.
|
||||
|
||||
The interaction-service identity is provisioning data, not a source identifier: the roster and per-agent environment carry the chosen display/roster name into a generic systemd instance. The service rejects a name mismatch or any drift from its pinned Pi/GPT-5.6 Sol/high/operator-interaction effective policy before launch. Its policy printer exposes only those resolved safe fields.
|
||||
|
||||
17
docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md
Normal file
17
docs/tess/M4-003-OPERATOR-PLUGIN-SKETCH.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# TESS-M4-003 Operator Plugin Sketch
|
||||
|
||||
## Memory/retrieval slice — TESS-MEM-001
|
||||
|
||||
Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context uses a bounded candidate window ordered so project/flat-file truth takes precedence within returned material.
|
||||
|
||||
Registration remains replaceable-adapter based: the existing `registerMemoryAdapter(kind, factory)` / `createMemoryAdapter(config)` seam supplies the injected adapter to `createOperatorMemoryPlugin(config)`. Identity and namespace are configuration data; no interaction-agent name is embedded in keys or defaults.
|
||||
|
||||
## Remaining plugin foundations — TESS-PLG-001
|
||||
|
||||
- `packages/agent`: capability descriptors for runtime bootstrap, durable inbox/state hooks, and read-only fleet diagnostics. Each capability advertises supported operations and fails closed when absent.
|
||||
- `packages/mosaic`: a catalog/registration surface for GitOps, fleet diagnostics, runtime bootstrap, Discord, and MCP/skill discovery. Catalog entries describe authority, input schema, and safe/read-only status; they do not invoke provider transports directly.
|
||||
- Gateway/channel adapters consume these contracts through server-derived actor/tenant context and durable session state, preserving the replaceable-adapter boundary.
|
||||
|
||||
## First implementation boundary
|
||||
|
||||
The first PR slice should add the operator-memory plugin contract, configuration-injected adapter seam, scope isolation, provenance-bearing retrieval, and tests for namespace isolation plus a differently named configured instance. Durable inbox/outbox remains owned by the existing `DurableSessionCoordinator`; this plugin only supplies bounded context/capture at lifecycle boundaries.
|
||||
83
docs/tess/MOS-COORDINATION.md
Normal file
83
docs/tess/MOS-COORDINATION.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Tess–Mos Coordination Contract Sketch
|
||||
|
||||
**Task:** TESS-M4-001 · **PRD:** TESS-MOS-001 / AC-TESS-04
|
||||
|
||||
## Boundary
|
||||
|
||||
Agent identities are deployment data. A configured interaction agent may request
|
||||
Mos-owned work; the configured orchestration agent owns decomposition, worker
|
||||
assignment, reviews, and merge decisions. The interaction agent receives a
|
||||
correlated receipt, read-only activity projection, and terminal result. It has
|
||||
no dispatch, assignment, review, merge, or cancellation operation.
|
||||
|
||||
## `@mosaicstack/coord` interface
|
||||
|
||||
```ts
|
||||
interface CoordinationScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly correlationId: string;
|
||||
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||
}
|
||||
|
||||
interface MosHandoffRequest {
|
||||
readonly idempotencyKey: string;
|
||||
readonly summary: string;
|
||||
readonly context?: string;
|
||||
readonly missionId?: string;
|
||||
}
|
||||
|
||||
interface MosHandoffReceipt {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'accepted' | 'queued';
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
interface MosHandoff {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly request: MosHandoffRequest;
|
||||
readonly scope: CoordinationScope;
|
||||
}
|
||||
|
||||
interface MosCoordinationPort {
|
||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||
}
|
||||
```
|
||||
|
||||
The port deliberately omits generic orchestrator verbs. It is tenant- and
|
||||
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||
and the requester agent from trusted authentication/configuration only.
|
||||
|
||||
## Enforcement point
|
||||
|
||||
`apps/gateway` owns a `MosCoordinationService` boundary that compares the
|
||||
trusted configured requester/target identities and rejects all of the following
|
||||
before calling a transport: unconfigured requester, self-delegation, target
|
||||
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
||||
receive a result for a handoff outside the originating tenant. The service exposes handoff, observe,
|
||||
and result only, and delegates delivery to an injected adapter.
|
||||
|
||||
M4 ships a native in-process `InMemoryMosCoordinationPort` as the concrete,
|
||||
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
||||
|
||||
A future fleet/tmux adapter is a documented M5 deployment seam and must
|
||||
implement the same `MosCoordinationPort`; no channel client or interaction
|
||||
runtime calls a transport directly.
|
||||
|
||||
## Required tests
|
||||
|
||||
1. A configured non-default interaction identity can hand off work to a
|
||||
configured non-default orchestration identity and receive its result.
|
||||
2. The gateway passes only server-derived scope/identity to the adapter.
|
||||
3. Self-targeting, target drift, and cross-tenant observe/result all fail closed
|
||||
without invoking the adapter.
|
||||
4. The exported public contract has no worker-dispatch, assignment, review,
|
||||
merge, or cancellation capability.
|
||||
5. The native adapter round-trips queued work, activity, and a host-recorded
|
||||
terminal result without a live fleet dependency.
|
||||
@@ -5,7 +5,7 @@
|
||||
| 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-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V |
|
||||
| AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | 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 |
|
||||
|
||||
19
docs/tess/hermes-runtime-adapter-design.md
Normal file
19
docs/tess/hermes-runtime-adapter-design.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# TESS-HRM-001 — Hermes runtime adapter boundary
|
||||
|
||||
## Normalized provider surface
|
||||
|
||||
`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`.
|
||||
|
||||
The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call.
|
||||
|
||||
## Boundary line
|
||||
|
||||
**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response.
|
||||
|
||||
## Initial mapping and safety posture
|
||||
|
||||
- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists.
|
||||
- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state.
|
||||
- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event.
|
||||
- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority.
|
||||
- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists.
|
||||
77
packages/agent/src/hermes-runtime-provider.test.ts
Normal file
77
packages/agent/src/hermes-runtime-provider.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RuntimeScope } from '@mosaicstack/types';
|
||||
import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js';
|
||||
|
||||
const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' };
|
||||
const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({
|
||||
capabilities: vi.fn(async () => capabilities),
|
||||
health: vi.fn(async () => ({ status: 'healthy' })),
|
||||
sessions: vi.fn(async () => [
|
||||
{
|
||||
conversation_id: 'child',
|
||||
agent_id: 'hermes-a',
|
||||
parent_conversation_id: 'parent',
|
||||
status: 'running',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
conversation_id: 'parent',
|
||||
agent_id: 'hermes-a',
|
||||
status: 'unknown',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
]),
|
||||
stream: async function* () {},
|
||||
send: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
});
|
||||
describe('HermesRuntimeProvider normalization boundary', () => {
|
||||
it('returns an exhaustive fail-closed transitional capability matrix', async () => {
|
||||
const provider = new HermesRuntimeProvider(transport());
|
||||
|
||||
await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([
|
||||
{ capability: 'kanban', status: 'unsupported' },
|
||||
{ capability: 'skills', status: 'unsupported' },
|
||||
{ capability: 'memory', status: 'unsupported' },
|
||||
{ capability: 'tools', status: 'unsupported' },
|
||||
{ capability: 'cron', status: 'unsupported' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('denies unsupported transitional capabilities without calling Hermes', async () => {
|
||||
const hermes = transport();
|
||||
const provider = new HermesRuntimeProvider(hermes);
|
||||
|
||||
await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({
|
||||
code: 'capability_unsupported',
|
||||
});
|
||||
expect(hermes.capabilities).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes legacy sessions without exposing legacy fields', async () => {
|
||||
const provider = new HermesRuntimeProvider(transport());
|
||||
await expect(provider.listSessions(scope)).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }),
|
||||
]),
|
||||
);
|
||||
const result = await provider.listSessions(scope);
|
||||
expect(result[0]).not.toHaveProperty('conversation_id');
|
||||
});
|
||||
it('forms normalized hierarchy and fails closed for unbridged operations', async () => {
|
||||
const provider = new HermesRuntimeProvider(transport());
|
||||
await expect(provider.getSessionTree(scope)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
session: expect.objectContaining({ id: 'parent', state: 'failed' }),
|
||||
children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })],
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope),
|
||||
).rejects.toMatchObject({ code: 'capability_unsupported' });
|
||||
});
|
||||
});
|
||||
217
packages/agent/src/hermes-runtime-provider.ts
Normal file
217
packages/agent/src/hermes-runtime-provider.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeAttachMode,
|
||||
RuntimeCapability,
|
||||
RuntimeCapabilitySet,
|
||||
RuntimeHealth,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeSession,
|
||||
RuntimeSessionState,
|
||||
RuntimeSessionTree,
|
||||
RuntimeStreamEvent,
|
||||
TransitionalCapabilityInventoryEntry,
|
||||
TransitionalCapabilityInventoryProvider,
|
||||
TransitionalRuntimeCapability,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
const HERMES_PROVIDER_ID = 'runtime.hermes';
|
||||
const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [
|
||||
'kanban',
|
||||
'skills',
|
||||
'memory',
|
||||
'tools',
|
||||
'cron',
|
||||
];
|
||||
const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.stream',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
];
|
||||
|
||||
/** Legacy transport boundary. These shapes are intentionally adapter-local. */
|
||||
export interface HermesLegacySession {
|
||||
conversation_id: string;
|
||||
agent_id: string;
|
||||
parent_conversation_id?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface HermesRuntimeTransport {
|
||||
capabilities(scope: RuntimeScope): Promise<string[]>;
|
||||
health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>;
|
||||
sessions(scope: RuntimeScope): Promise<HermesLegacySession[]>;
|
||||
stream(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
send(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>;
|
||||
}
|
||||
|
||||
export class HermesRuntimeProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: 'capability_unsupported' | 'invalid_request',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = HermesRuntimeProviderError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitional Hermes adapter. Legacy identifiers and schemas do not cross this
|
||||
* boundary: callers only observe Mosaic AgentRuntimeProvider contracts.
|
||||
*/
|
||||
export class HermesRuntimeProvider
|
||||
implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider
|
||||
{
|
||||
readonly id = HERMES_PROVIDER_ID;
|
||||
|
||||
constructor(private readonly transport: HermesRuntimeTransport) {}
|
||||
|
||||
/**
|
||||
* Full AC-TESS-05 migration inventory. These operations are deliberately
|
||||
* unsupported until their Mosaic-owned plugin contracts exist.
|
||||
*/
|
||||
async transitionalCapabilityMatrix(
|
||||
_scope: RuntimeScope,
|
||||
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
||||
return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' }));
|
||||
}
|
||||
|
||||
async assertTransitionalCapability(
|
||||
capability: TransitionalRuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
const entry = (await this.transitionalCapabilityMatrix(scope)).find(
|
||||
(candidate) => candidate.capability === capability,
|
||||
);
|
||||
if (!entry || entry.status !== 'supported') {
|
||||
throw new HermesRuntimeProviderError(
|
||||
'capability_unsupported',
|
||||
`Hermes transitional capability is unsupported: ${capability}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||
const legacyCapabilities = await this.transport.capabilities(scope);
|
||||
return {
|
||||
supported: RUNTIME_CAPABILITIES.filter((capability) =>
|
||||
legacyCapabilities.includes(capability),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||
const health = await this.transport.health(scope);
|
||||
return {
|
||||
status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down',
|
||||
checkedAt: new Date().toISOString(),
|
||||
...(health.detail ? { detail: health.detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||
await this.requireCapability('session.list', scope);
|
||||
return (await this.transport.sessions(scope)).map((session) => this.session(session));
|
||||
}
|
||||
|
||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||
await this.requireCapability('session.tree', scope);
|
||||
const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session));
|
||||
const nodes = new Map<string, RuntimeSessionTree>(
|
||||
sessions.map((session) => [session.id, { session, children: [] }]),
|
||||
);
|
||||
const roots: RuntimeSessionTree[] = [];
|
||||
for (const session of sessions) {
|
||||
const node = nodes.get(session.id)!;
|
||||
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async *streamSession(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
await this.requireCapability('session.stream', scope);
|
||||
yield* this.transport.stream(sessionId, cursor, scope);
|
||||
}
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
message: RuntimeMessage,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
await this.requireCapability('session.send', scope);
|
||||
if (!message.content.trim())
|
||||
throw new HermesRuntimeProviderError('invalid_request', 'Message content is required');
|
||||
await this.transport.send(sessionId, message, scope);
|
||||
}
|
||||
async attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle> {
|
||||
await this.requireCapability('session.attach', scope);
|
||||
return this.transport.attach(sessionId, mode, scope);
|
||||
}
|
||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.transport.detach(attachmentId, scope);
|
||||
}
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.requireCapability('session.terminate', scope);
|
||||
if (!approvalRef.trim())
|
||||
throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required');
|
||||
await this.transport.terminate(sessionId, approvalRef, scope);
|
||||
}
|
||||
|
||||
private async requireCapability(
|
||||
capability: RuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
if (!(await this.capabilities(scope)).supported.includes(capability)) {
|
||||
throw new HermesRuntimeProviderError(
|
||||
'capability_unsupported',
|
||||
`Hermes does not bridge ${capability}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
private session(value: HermesLegacySession): RuntimeSession {
|
||||
return {
|
||||
id: value.conversation_id,
|
||||
providerId: this.id,
|
||||
runtimeId: value.agent_id,
|
||||
...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}),
|
||||
state: state(value.status),
|
||||
createdAt: value.created_at,
|
||||
updatedAt: value.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
function state(value: string): RuntimeSessionState {
|
||||
return (
|
||||
(
|
||||
{ running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record<
|
||||
string,
|
||||
RuntimeSessionState
|
||||
>
|
||||
)[value] ?? 'failed'
|
||||
);
|
||||
}
|
||||
@@ -2,3 +2,5 @@ export const VERSION = '0.0.0';
|
||||
|
||||
export * from './runtime-provider-registry.js';
|
||||
export * from './tmux-fleet-runtime-provider.js';
|
||||
export * from './hermes-runtime-provider.js';
|
||||
export * from './tess-durable-session.js';
|
||||
|
||||
305
packages/agent/src/tess-durable-session.test.ts
Normal file
305
packages/agent/src/tess-durable-session.test.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DurableSessionCoordinator,
|
||||
InMemoryDurableSessionStore,
|
||||
type DurableSessionIdentity,
|
||||
} from './tess-durable-session.js';
|
||||
|
||||
const IDENTITY: DurableSessionIdentity = {
|
||||
agentName: 'Nova',
|
||||
sessionId: 'tess-session-1',
|
||||
tenantId: 'tenant-1',
|
||||
ownerId: 'owner-1',
|
||||
providerId: 'fleet',
|
||||
runtimeSessionId: 'nova',
|
||||
};
|
||||
|
||||
describe('DurableSessionCoordinator', () => {
|
||||
it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const beforeRestart = new DurableSessionCoordinator(store);
|
||||
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-1',
|
||||
correlationId: 'correlation-1',
|
||||
content: 'continue the session',
|
||||
});
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-1',
|
||||
correlationId: 'correlation-1',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'resumable response',
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
cursor: 'cursor-42',
|
||||
summary: 'operator asked for recovery proof',
|
||||
compactionEpoch: 0,
|
||||
});
|
||||
await beforeRestart.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-1',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Simulate an ungraceful process death: no in-memory coordinator state survives.
|
||||
const afterRestart = new DurableSessionCoordinator(store);
|
||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||
|
||||
expect(recovered.identity).toEqual(IDENTITY);
|
||||
expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]);
|
||||
expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]);
|
||||
expect(recovered.checkpoint).toMatchObject({
|
||||
checkpointId: 'checkpoint-1',
|
||||
cursor: 'cursor-42',
|
||||
});
|
||||
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]);
|
||||
});
|
||||
|
||||
it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore());
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.create({
|
||||
...IDENTITY,
|
||||
providerId: 'fleet-next',
|
||||
runtimeSessionId: 'nova-next',
|
||||
});
|
||||
|
||||
await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({
|
||||
identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' },
|
||||
});
|
||||
await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow(
|
||||
/identity conflict/,
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const firstProcess = new DurableSessionCoordinator(store);
|
||||
const handled: string[] = [];
|
||||
|
||||
await firstProcess.create(IDENTITY);
|
||||
await expect(
|
||||
firstProcess.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-duplicate',
|
||||
correlationId: 'correlation-2',
|
||||
content: 'only process me once',
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: true });
|
||||
await expect(
|
||||
firstProcess.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-duplicate',
|
||||
correlationId: 'correlation-2',
|
||||
content: 'only process me once',
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: false, status: 'pending' });
|
||||
await expect(
|
||||
firstProcess.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-duplicate',
|
||||
correlationId: 'forged-correlation',
|
||||
content: 'only process me once',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
|
||||
await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
await firstProcess.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-after-inbox',
|
||||
cursor: 'cursor-43',
|
||||
summary: 'safe to compact',
|
||||
compactionEpoch: 1,
|
||||
});
|
||||
|
||||
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
|
||||
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
|
||||
await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(handled).toEqual(['ingress-duplicate']);
|
||||
});
|
||||
|
||||
it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const beforeRestart = new DurableSessionCoordinator(store);
|
||||
const appliedEffects: string[] = [];
|
||||
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'effect-1',
|
||||
correlationId: 'correlation-3',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'send exactly once',
|
||||
});
|
||||
await expect(
|
||||
beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'effect-1',
|
||||
correlationId: 'correlation-3',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'send exactly once',
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: false, status: 'pending' });
|
||||
|
||||
await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
|
||||
appliedEffects.push(entry.idempotencyKey);
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-after-effect',
|
||||
cursor: 'cursor-44',
|
||||
summary: 'effect persisted before compaction',
|
||||
compactionEpoch: 1,
|
||||
});
|
||||
|
||||
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
|
||||
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
|
||||
await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
|
||||
appliedEffects.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(appliedEffects).toEqual(['effect-1']);
|
||||
});
|
||||
|
||||
it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const coordinator = new DurableSessionCoordinator(store);
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'original effect',
|
||||
});
|
||||
await expect(
|
||||
coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'forged-channel',
|
||||
kind: 'provider.send',
|
||||
content: 'original effect',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
});
|
||||
|
||||
it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const beforeRestart = new DurableSessionCoordinator(store);
|
||||
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ambiguous-effect',
|
||||
correlationId: 'correlation-ambiguous',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'preserve this effect claim',
|
||||
});
|
||||
await expect(
|
||||
beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise<void> => {
|
||||
throw new Error('provider connection dropped after submit');
|
||||
}),
|
||||
).rejects.toThrow(/connection dropped/);
|
||||
|
||||
const afterRestart = new DurableSessionCoordinator(store);
|
||||
await afterRestart.recover(IDENTITY.sessionId);
|
||||
const calls: string[] = [];
|
||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
calls.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a handoff-id replay with different immutable state', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const coordinator = new DurableSessionCoordinator(store);
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-conflict',
|
||||
cursor: 'cursor-conflict',
|
||||
summary: 'handoff conflict proof',
|
||||
compactionEpoch: 0,
|
||||
});
|
||||
await coordinator.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-conflict',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-conflict',
|
||||
checkpointId: 'checkpoint-conflict',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-conflict',
|
||||
destination: 'forged-destination',
|
||||
correlationId: 'correlation-conflict',
|
||||
checkpointId: 'checkpoint-conflict',
|
||||
status: 'pending',
|
||||
}),
|
||||
).rejects.toThrow(/identity conflict/);
|
||||
});
|
||||
|
||||
it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const source = new DurableSessionCoordinator(store);
|
||||
|
||||
await source.create(IDENTITY);
|
||||
await source.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-handoff',
|
||||
cursor: 'cursor-45',
|
||||
summary: 'portable state',
|
||||
compactionEpoch: 2,
|
||||
});
|
||||
await source.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-portable',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-4',
|
||||
checkpointId: 'checkpoint-handoff',
|
||||
status: 'pending',
|
||||
});
|
||||
await source.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-later',
|
||||
cursor: 'cursor-46',
|
||||
summary: 'newer compacted state must not strand the handoff',
|
||||
compactionEpoch: 3,
|
||||
});
|
||||
|
||||
const receivingProcess = new DurableSessionCoordinator(store);
|
||||
const handoff = await receivingProcess.resumeHandoff('handoff-portable');
|
||||
|
||||
expect(handoff.identity).toEqual(IDENTITY);
|
||||
expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' });
|
||||
expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' });
|
||||
});
|
||||
});
|
||||
498
packages/agent/src/tess-durable-session.ts
Normal file
498
packages/agent/src/tess-durable-session.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
export interface DurableSessionIdentity {
|
||||
/** Provisioned roster identity; isolates durable state between named agents. */
|
||||
agentName: string;
|
||||
sessionId: string;
|
||||
tenantId: string;
|
||||
ownerId: string;
|
||||
providerId: string;
|
||||
runtimeSessionId: string;
|
||||
}
|
||||
|
||||
export type DurableInboxStatus = 'pending' | 'processing' | 'processed';
|
||||
export type DurableOutboxStatus = 'pending' | 'processing' | 'delivered';
|
||||
|
||||
export interface DurableInboxInput {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DurableInboxEntry extends DurableInboxInput {
|
||||
status: DurableInboxStatus;
|
||||
}
|
||||
|
||||
export interface DurableOutboxInput {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
channelId: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DurableOutboxEntry extends DurableOutboxInput {
|
||||
status: DurableOutboxStatus;
|
||||
}
|
||||
|
||||
export interface DurableCheckpointInput {
|
||||
sessionId: string;
|
||||
checkpointId: string;
|
||||
cursor: string;
|
||||
summary: string;
|
||||
compactionEpoch: number;
|
||||
}
|
||||
|
||||
export interface DurableCheckpoint extends DurableCheckpointInput {}
|
||||
|
||||
export interface DurableHandoffInput {
|
||||
sessionId: string;
|
||||
handoffId: string;
|
||||
destination: string;
|
||||
correlationId: string;
|
||||
checkpointId: string;
|
||||
status: 'pending' | 'accepted';
|
||||
}
|
||||
|
||||
export interface DurableHandoff extends DurableHandoffInput {}
|
||||
|
||||
export interface DurableSessionSnapshot {
|
||||
identity: DurableSessionIdentity;
|
||||
inbox: DurableInboxEntry[];
|
||||
outbox: DurableOutboxEntry[];
|
||||
checkpoint?: DurableCheckpoint;
|
||||
handoffs: DurableHandoff[];
|
||||
}
|
||||
|
||||
export interface DurableHandoffRecovery {
|
||||
identity: DurableSessionIdentity;
|
||||
checkpoint: DurableCheckpoint;
|
||||
handoff: DurableHandoff;
|
||||
}
|
||||
|
||||
export interface DurableEnqueueResult<TStatus extends string> {
|
||||
accepted: boolean;
|
||||
status: TStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable-state port. Implementations must atomically claim and complete work
|
||||
* records. Recovery may requeue interrupted inbox work, but never an
|
||||
* externally visible outbox effect: an ambiguous provider result remains
|
||||
* claimed until a separately authorized reconciliation proves it safe.
|
||||
*/
|
||||
export interface DurableSessionStore {
|
||||
create(identity: DurableSessionIdentity): Promise<void>;
|
||||
snapshot(sessionId: string): Promise<DurableSessionSnapshot | null>;
|
||||
enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>>;
|
||||
claimInbox(sessionId: string): Promise<DurableInboxEntry | null>;
|
||||
completeInbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
releaseInbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
enqueueOutbox(input: DurableOutboxInput): Promise<DurableEnqueueResult<DurableOutboxStatus>>;
|
||||
claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null>;
|
||||
claimOutboxByKey(sessionId: string, idempotencyKey: string): Promise<DurableOutboxEntry | null>;
|
||||
completeOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
checkpoint(input: DurableCheckpointInput): Promise<void>;
|
||||
findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null>;
|
||||
handoff(input: DurableHandoffInput): Promise<void>;
|
||||
findHandoff(handoffId: string): Promise<DurableHandoff | null>;
|
||||
requeueInFlight(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class DurableSessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Durable Tess session not found: ${sessionId}`);
|
||||
this.name = 'DurableSessionNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DurableSessionCoordinator {
|
||||
constructor(private readonly store: DurableSessionStore) {}
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
this.assertIdentity(identity);
|
||||
await this.store.create(identity);
|
||||
}
|
||||
|
||||
async receive(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||
this.assertRecord(input.sessionId, input.idempotencyKey, input.correlationId, input.content);
|
||||
return this.store.enqueueInbox(input);
|
||||
}
|
||||
|
||||
async enqueueOutbox(
|
||||
input: DurableOutboxInput,
|
||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||
this.assertRecord(
|
||||
input.sessionId,
|
||||
input.idempotencyKey,
|
||||
input.correlationId,
|
||||
input.channelId,
|
||||
input.content,
|
||||
);
|
||||
if (input.kind.trim().length === 0) throw new Error('Durable outbox kind is required');
|
||||
return this.store.enqueueOutbox(input);
|
||||
}
|
||||
|
||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||
this.assertRecord(input.sessionId, input.checkpointId, input.cursor, input.summary);
|
||||
if (!Number.isInteger(input.compactionEpoch) || input.compactionEpoch < 0) {
|
||||
throw new Error('Durable checkpoint compaction epoch must be a non-negative integer');
|
||||
}
|
||||
await this.store.checkpoint(input);
|
||||
}
|
||||
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
this.assertRecord(input.sessionId, input.handoffId, input.destination, input.correlationId);
|
||||
if (input.checkpointId.trim().length === 0)
|
||||
throw new Error('Durable handoff checkpoint is required');
|
||||
await this.store.handoff(input);
|
||||
}
|
||||
|
||||
/** Read durable state without changing claim status; safe during normal operation. */
|
||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot> {
|
||||
const snapshot = await this.store.snapshot(sessionId);
|
||||
if (!snapshot) throw new DurableSessionNotFoundError(sessionId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Requeue interrupted inbox work during recovery; preserve ambiguous outbox claims. */
|
||||
async recover(sessionId: string): Promise<DurableSessionSnapshot> {
|
||||
await this.store.requeueInFlight(sessionId);
|
||||
return this.snapshot(sessionId);
|
||||
}
|
||||
|
||||
async drainInbox(
|
||||
sessionId: string,
|
||||
handler: (entry: DurableInboxEntry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const entry = await this.store.claimInbox(sessionId);
|
||||
if (!entry) return;
|
||||
try {
|
||||
await handler(entry);
|
||||
} catch (error: unknown) {
|
||||
await this.store.releaseInbox(sessionId, entry.idempotencyKey);
|
||||
throw error;
|
||||
}
|
||||
// If this write fails after the handler succeeded, leave the record
|
||||
// processing. A recovery path can retry it with its stable idempotency key.
|
||||
await this.store.completeInbox(sessionId, entry.idempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchOutbox(
|
||||
sessionId: string,
|
||||
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const entry = await this.store.claimOutbox(sessionId);
|
||||
if (!entry) return;
|
||||
// A provider failure can be ambiguous: it may occur after the receiver
|
||||
// accepted the idempotency key. Preserve the claim for reconciliation.
|
||||
await dispatcher(entry);
|
||||
// Do not requeue an effect after it has been applied but before its
|
||||
// terminal state could be persisted; recovery preserves the claim.
|
||||
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchOutboxEntry(
|
||||
sessionId: string,
|
||||
idempotencyKey: string,
|
||||
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const entry = await this.store.claimOutboxByKey(sessionId, idempotencyKey);
|
||||
if (!entry) return;
|
||||
// A provider failure can be ambiguous, so this stays processing until
|
||||
// separately authorized reconciliation proves it safe to resolve.
|
||||
await dispatcher(entry);
|
||||
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
|
||||
}
|
||||
|
||||
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
||||
const handoff = await this.store.findHandoff(handoffId);
|
||||
if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`);
|
||||
const snapshot = await this.snapshot(handoff.sessionId);
|
||||
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
||||
}
|
||||
return { identity: snapshot.identity, checkpoint, handoff };
|
||||
}
|
||||
|
||||
private assertIdentity(identity: DurableSessionIdentity): void {
|
||||
this.assertRecord(
|
||||
identity.agentName,
|
||||
identity.sessionId,
|
||||
identity.tenantId,
|
||||
identity.ownerId,
|
||||
identity.providerId,
|
||||
);
|
||||
if (identity.runtimeSessionId.trim().length === 0) {
|
||||
throw new Error('Durable runtime session identity is required');
|
||||
}
|
||||
}
|
||||
|
||||
private assertRecord(...values: string[]): void {
|
||||
if (values.some((value: string): boolean => value.trim().length === 0)) {
|
||||
throw new Error('Durable session records require non-empty fields');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface InMemorySessionState {
|
||||
identity: DurableSessionIdentity;
|
||||
inbox: Map<string, DurableInboxEntry>;
|
||||
outbox: Map<string, DurableOutboxEntry>;
|
||||
checkpoints: Map<string, DurableCheckpoint>;
|
||||
handoffs: Map<string, DurableHandoff>;
|
||||
}
|
||||
|
||||
/** Reference store for deterministic domain tests; production uses the gateway DB adapter. */
|
||||
export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
private readonly sessions = new Map<string, InMemorySessionState>();
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
const existing = this.sessions.get(identity.sessionId);
|
||||
if (existing) {
|
||||
if (!sameEnrollmentScope(existing.identity, identity)) {
|
||||
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||
}
|
||||
existing.identity.providerId = identity.providerId;
|
||||
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
||||
return;
|
||||
}
|
||||
this.sessions.set(identity.sessionId, {
|
||||
identity: copyIdentity(identity),
|
||||
inbox: new Map(),
|
||||
outbox: new Map(),
|
||||
checkpoints: new Map(),
|
||||
handoffs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
||||
const state = this.sessions.get(sessionId);
|
||||
if (!state) return null;
|
||||
const checkpoint = latestCheckpoint(state.checkpoints);
|
||||
return {
|
||||
identity: copyIdentity(state.identity),
|
||||
inbox: [...state.inbox.values()].map(copyInbox),
|
||||
outbox: [...state.outbox.values()].map(copyOutbox),
|
||||
...(checkpoint ? { checkpoint: copyCheckpoint(checkpoint) } : {}),
|
||||
handoffs: [...state.handoffs.values()].map(copyHandoff),
|
||||
};
|
||||
}
|
||||
|
||||
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||
const state = this.require(input.sessionId);
|
||||
const existing = state.inbox.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (!sameInbox(existing, input)) {
|
||||
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: existing.status };
|
||||
}
|
||||
state.inbox.set(input.idempotencyKey, { ...input, status: 'pending' });
|
||||
return { accepted: true, status: 'pending' };
|
||||
}
|
||||
|
||||
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
||||
const state = this.require(sessionId);
|
||||
const entry = [...state.inbox.values()].find(
|
||||
(candidate: DurableInboxEntry): boolean => candidate.status === 'pending',
|
||||
);
|
||||
if (!entry) return null;
|
||||
entry.status = 'processing';
|
||||
return copyInbox(entry);
|
||||
}
|
||||
|
||||
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'processed';
|
||||
}
|
||||
|
||||
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'pending';
|
||||
}
|
||||
|
||||
async enqueueOutbox(
|
||||
input: DurableOutboxInput,
|
||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||
const state = this.require(input.sessionId);
|
||||
const existing = state.outbox.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (!sameOutbox(existing, input)) {
|
||||
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: existing.status };
|
||||
}
|
||||
state.outbox.set(input.idempotencyKey, { ...input, status: 'pending' });
|
||||
return { accepted: true, status: 'pending' };
|
||||
}
|
||||
|
||||
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
||||
const state = this.require(sessionId);
|
||||
const entry = [...state.outbox.values()].find(
|
||||
(candidate: DurableOutboxEntry): boolean => candidate.status === 'pending',
|
||||
);
|
||||
if (!entry) return null;
|
||||
entry.status = 'processing';
|
||||
return copyOutbox(entry);
|
||||
}
|
||||
|
||||
async claimOutboxByKey(
|
||||
sessionId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<DurableOutboxEntry | null> {
|
||||
const entry = this.require(sessionId).outbox.get(idempotencyKey);
|
||||
if (!entry || entry.status !== 'pending') return null;
|
||||
entry.status = 'processing';
|
||||
return copyOutbox(entry);
|
||||
}
|
||||
|
||||
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status =
|
||||
'delivered';
|
||||
}
|
||||
|
||||
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = 'pending';
|
||||
}
|
||||
|
||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||
const checkpoints = this.require(input.sessionId).checkpoints;
|
||||
const existing = checkpoints.get(input.checkpointId);
|
||||
if (existing && !sameCheckpoint(existing, input)) {
|
||||
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||
}
|
||||
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
||||
}
|
||||
|
||||
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
||||
const checkpoint = this.require(sessionId).checkpoints.get(checkpointId);
|
||||
return checkpoint ? copyCheckpoint(checkpoint) : null;
|
||||
}
|
||||
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
const state = this.require(input.sessionId);
|
||||
if (!state.checkpoints.has(input.checkpointId)) {
|
||||
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
}
|
||||
const existing = state.handoffs.get(input.handoffId);
|
||||
if (existing && !sameHandoff(existing, input)) {
|
||||
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||
}
|
||||
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
||||
}
|
||||
|
||||
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
||||
for (const state of this.sessions.values()) {
|
||||
const handoff = state.handoffs.get(handoffId);
|
||||
if (handoff) return copyHandoff(handoff);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async requeueInFlight(sessionId: string): Promise<void> {
|
||||
const state = this.require(sessionId);
|
||||
for (const entry of state.inbox.values()) {
|
||||
if (entry.status === 'processing') entry.status = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private require(sessionId: string): InMemorySessionState {
|
||||
const state = this.sessions.get(sessionId);
|
||||
if (!state) throw new DurableSessionNotFoundError(sessionId);
|
||||
return state;
|
||||
}
|
||||
|
||||
private requireEntry<T extends { status: string }>(
|
||||
records: Map<string, T>,
|
||||
idempotencyKey: string,
|
||||
kind: string,
|
||||
): T {
|
||||
const entry = records.get(idempotencyKey);
|
||||
if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
||||
return (
|
||||
left.agentName === right.agentName &&
|
||||
left.sessionId === right.sessionId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.ownerId === right.ownerId
|
||||
);
|
||||
}
|
||||
|
||||
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.channelId === right.channelId &&
|
||||
left.kind === right.kind &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.cursor === right.cursor &&
|
||||
left.summary === right.summary &&
|
||||
left.compactionEpoch === right.compactionEpoch
|
||||
);
|
||||
}
|
||||
|
||||
function latestCheckpoint(
|
||||
checkpoints: Map<string, DurableCheckpoint>,
|
||||
): DurableCheckpoint | undefined {
|
||||
return [...checkpoints.values()].sort(
|
||||
(left: DurableCheckpoint, right: DurableCheckpoint): number =>
|
||||
right.compactionEpoch - left.compactionEpoch,
|
||||
)[0];
|
||||
}
|
||||
|
||||
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.handoffId === right.handoffId &&
|
||||
left.destination === right.destination &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.status === right.status
|
||||
);
|
||||
}
|
||||
|
||||
function copyIdentity(identity: DurableSessionIdentity): DurableSessionIdentity {
|
||||
return { ...identity };
|
||||
}
|
||||
|
||||
function copyInbox(entry: DurableInboxEntry): DurableInboxEntry {
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function copyOutbox(entry: DurableOutboxEntry): DurableOutboxEntry {
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function copyCheckpoint(checkpoint: DurableCheckpoint): DurableCheckpoint {
|
||||
return { ...checkpoint };
|
||||
}
|
||||
|
||||
function copyHandoff(handoff: DurableHandoff): DurableHandoff {
|
||||
return { ...handoff };
|
||||
}
|
||||
154
packages/coord/src/__tests__/mos-coordination.test.ts
Normal file
154
packages/coord/src/__tests__/mos-coordination.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
InMemoryMosCoordinationPort,
|
||||
MosCoordinationClient,
|
||||
type CoordinationScope,
|
||||
type MosCoordinationAuthorityError,
|
||||
type MosCoordinationPort,
|
||||
} from '../index.js';
|
||||
|
||||
const scope: CoordinationScope = {
|
||||
actorId: 'operator-1',
|
||||
tenantId: 'tenant-a',
|
||||
correlationId: 'corr-1',
|
||||
requesterAgentId: 'Nova',
|
||||
};
|
||||
|
||||
function client(
|
||||
port: MosCoordinationPort,
|
||||
handoffIdFactory: () => string = (): string => 'handoff-1',
|
||||
): MosCoordinationClient {
|
||||
return new MosCoordinationClient(
|
||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
||||
port,
|
||||
handoffIdFactory,
|
||||
);
|
||||
}
|
||||
|
||||
describe('MosCoordinationClient', (): void => {
|
||||
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const coordination = client(adapter);
|
||||
|
||||
await expect(
|
||||
coordination.handoff(
|
||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||
scope,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
handoffId: 'handoff-1',
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'queued',
|
||||
correlationId: 'corr-1',
|
||||
});
|
||||
|
||||
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
|
||||
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
|
||||
|
||||
await expect(coordination.observe('handoff-1', scope)).resolves.toMatchObject({
|
||||
status: 'completed',
|
||||
targetAgentId: 'Conductor',
|
||||
activity: expect.arrayContaining([
|
||||
expect.objectContaining({ status: 'queued' }),
|
||||
expect.objectContaining({ status: 'running' }),
|
||||
expect.objectContaining({ status: 'completed' }),
|
||||
]),
|
||||
});
|
||||
await expect(coordination.result('handoff-1', scope)).resolves.toEqual({
|
||||
handoffId: 'handoff-1',
|
||||
targetAgentId: 'Conductor',
|
||||
status: 'completed',
|
||||
correlationId: 'corr-1',
|
||||
summary: 'Merged by Mos',
|
||||
});
|
||||
|
||||
expect(coordination).not.toHaveProperty('dispatch');
|
||||
expect(coordination).not.toHaveProperty('assign');
|
||||
expect(coordination).not.toHaveProperty('review');
|
||||
expect(coordination).not.toHaveProperty('merge');
|
||||
expect(coordination).not.toHaveProperty('cancel');
|
||||
});
|
||||
|
||||
it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
|
||||
await expect(
|
||||
client(adapter).handoff(
|
||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||
{ ...scope, requesterAgentId: 'Untrusted' },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'requester_forbidden',
|
||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||
});
|
||||
|
||||
it('rejects self-delegation configuration before constructing a client', (): void => {
|
||||
expect(
|
||||
(): MosCoordinationClient =>
|
||||
new MosCoordinationClient(
|
||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
||||
new InMemoryMosCoordinationPort(),
|
||||
),
|
||||
).toThrow('Interaction and orchestration identities must differ');
|
||||
});
|
||||
|
||||
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
||||
expect(
|
||||
(): MosCoordinationClient =>
|
||||
new MosCoordinationClient(
|
||||
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
||||
new InMemoryMosCoordinationPort(),
|
||||
),
|
||||
).toThrow('Interaction and orchestration identities must differ');
|
||||
});
|
||||
|
||||
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort();
|
||||
const coordination = client(adapter);
|
||||
await coordination.handoff(
|
||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||
scope,
|
||||
);
|
||||
|
||||
const otherTenantScope = { ...scope, tenantId: 'tenant-b' };
|
||||
await expect(coordination.observe('handoff-1', otherTenantScope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
});
|
||||
await expect(coordination.result('handoff-1', otherTenantScope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
|
||||
const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 });
|
||||
const first = client(adapter, (): string => 'handoff-1');
|
||||
const second = client(adapter, (): string => 'handoff-2');
|
||||
await first.handoff({ idempotencyKey: 'handoff-request-1', summary: 'First request' }, scope);
|
||||
await second.handoff({ idempotencyKey: 'handoff-request-2', summary: 'Second request' }, scope);
|
||||
|
||||
await expect(first.observe('handoff-1', scope)).rejects.toMatchObject({ code: 'not_found' });
|
||||
await expect(second.observe('handoff-2', scope)).resolves.toMatchObject({ status: 'queued' });
|
||||
});
|
||||
|
||||
it('fails closed when a transport reports target drift', async (): Promise<void> => {
|
||||
const adapter: MosCoordinationPort = {
|
||||
handoff: vi.fn(async () => ({
|
||||
handoffId: 'handoff-1',
|
||||
targetAgentId: 'Unexpected',
|
||||
status: 'accepted' as const,
|
||||
correlationId: 'corr-1',
|
||||
})),
|
||||
observe: vi.fn(),
|
||||
result: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
client(adapter).handoff(
|
||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
||||
scope,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: 'target_drift',
|
||||
} satisfies Partial<MosCoordinationAuthorityError>);
|
||||
});
|
||||
});
|
||||
208
packages/coord/src/in-memory-mos-coordination-port.ts
Normal file
208
packages/coord/src/in-memory-mos-coordination-port.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import {
|
||||
type CoordinationObservation,
|
||||
type CoordinationResult,
|
||||
type CoordinationScope,
|
||||
type MosCoordinationActivity,
|
||||
type MosCoordinationPort,
|
||||
type MosHandoff,
|
||||
type MosHandoffReceipt,
|
||||
type MosHandoffStatus,
|
||||
} from './mos-coordination.js';
|
||||
|
||||
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
||||
const DEFAULT_MAX_HANDOFFS = 1_000;
|
||||
|
||||
interface StoredHandoff {
|
||||
readonly handoff: MosHandoff;
|
||||
status: MosHandoffStatus;
|
||||
readonly activity: MosCoordinationActivity[];
|
||||
readonly expiresAt: number;
|
||||
result?: CoordinationResult;
|
||||
}
|
||||
|
||||
export interface InMemoryMosCoordinationPortOptions {
|
||||
now?: () => Date;
|
||||
handoffTtlMs?: number;
|
||||
maxHandoffs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Native deterministic queue/port adapter for the coordination boundary.
|
||||
* It intentionally has no fleet/tmux dependency. A future deployment adapter
|
||||
* implements MosCoordinationPort without changing interaction-plane callers.
|
||||
*/
|
||||
export class InMemoryMosCoordinationPort implements MosCoordinationPort {
|
||||
private readonly handoffs = new Map<string, StoredHandoff>();
|
||||
private readonly now: () => Date;
|
||||
private readonly handoffTtlMs: number;
|
||||
private readonly maxHandoffs: number;
|
||||
|
||||
constructor(options: InMemoryMosCoordinationPortOptions = {}) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
||||
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
||||
}
|
||||
|
||||
async handoff(handoff: MosHandoff): Promise<MosHandoffReceipt> {
|
||||
this.pruneExpiredHandoffs();
|
||||
const existing = this.handoffs.get(handoff.handoffId);
|
||||
if (existing !== undefined) {
|
||||
this.assertSameHandoff(existing.handoff, handoff);
|
||||
return this.receipt(existing.handoff, existing.status);
|
||||
}
|
||||
|
||||
const stored: StoredHandoff = {
|
||||
handoff: snapshotHandoff(handoff),
|
||||
status: 'queued',
|
||||
activity: [activity('queued', 'Handoff accepted by the native coordination queue', this.now)],
|
||||
expiresAt: this.now().getTime() + this.handoffTtlMs,
|
||||
};
|
||||
this.handoffs.set(handoff.handoffId, stored);
|
||||
this.enforceHandoffLimit();
|
||||
return this.receipt(stored.handoff, stored.status);
|
||||
}
|
||||
|
||||
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
|
||||
this.pruneExpiredHandoffs();
|
||||
const stored = this.requireScopedHandoff(handoffId, scope);
|
||||
return {
|
||||
handoffId: stored.handoff.handoffId,
|
||||
targetAgentId: stored.handoff.targetAgentId,
|
||||
status: stored.status,
|
||||
correlationId: stored.handoff.scope.correlationId,
|
||||
activity: stored.activity.map(copyActivity),
|
||||
};
|
||||
}
|
||||
|
||||
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
|
||||
this.pruneExpiredHandoffs();
|
||||
const stored = this.requireScopedHandoff(handoffId, scope);
|
||||
return (
|
||||
stored.result ?? {
|
||||
handoffId: stored.handoff.handoffId,
|
||||
targetAgentId: stored.handoff.targetAgentId,
|
||||
status: 'pending',
|
||||
correlationId: stored.handoff.scope.correlationId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** Host-side progression seam; interaction clients never receive this capability. */
|
||||
recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void {
|
||||
this.pruneExpiredHandoffs();
|
||||
const stored = this.requireHandoff(handoffId);
|
||||
stored.status = status;
|
||||
stored.activity.push(activity(status, summary, this.now));
|
||||
}
|
||||
|
||||
/** Host-side result seam for deterministic qualification; not a Mos consumer. */
|
||||
recordResult(handoffId: string, status: 'completed' | 'failed', summary: string): void {
|
||||
this.pruneExpiredHandoffs();
|
||||
const stored = this.requireHandoff(handoffId);
|
||||
stored.status = status;
|
||||
stored.activity.push(activity(status, summary, this.now));
|
||||
stored.result = {
|
||||
handoffId: stored.handoff.handoffId,
|
||||
targetAgentId: stored.handoff.targetAgentId,
|
||||
status,
|
||||
correlationId: stored.handoff.scope.correlationId,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt {
|
||||
return {
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: handoff.targetAgentId,
|
||||
status: status === 'accepted' ? 'accepted' : 'queued',
|
||||
correlationId: handoff.scope.correlationId,
|
||||
};
|
||||
}
|
||||
|
||||
private pruneExpiredHandoffs(): void {
|
||||
const nowMs = this.now().getTime();
|
||||
for (const [handoffId, handoff] of this.handoffs) {
|
||||
if (handoff.expiresAt <= nowMs) this.handoffs.delete(handoffId);
|
||||
}
|
||||
}
|
||||
|
||||
private enforceHandoffLimit(): void {
|
||||
while (this.handoffs.size > this.maxHandoffs) {
|
||||
const oldest = this.handoffs.keys().next().value;
|
||||
if (typeof oldest !== 'string') return;
|
||||
this.handoffs.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private requireScopedHandoff(handoffId: string, scope: CoordinationScope): StoredHandoff {
|
||||
const stored = this.requireHandoff(handoffId);
|
||||
if (
|
||||
stored.handoff.scope.tenantId !== scope.tenantId ||
|
||||
stored.handoff.scope.actorId !== scope.actorId ||
|
||||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
|
||||
) {
|
||||
throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match');
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private requireHandoff(handoffId: string): StoredHandoff {
|
||||
const stored = this.handoffs.get(handoffId);
|
||||
if (stored === undefined) {
|
||||
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
|
||||
}
|
||||
return stored;
|
||||
}
|
||||
|
||||
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void {
|
||||
if (
|
||||
existing.targetAgentId !== incoming.targetAgentId ||
|
||||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
|
||||
existing.request.summary !== incoming.request.summary ||
|
||||
existing.request.context !== incoming.request.context ||
|
||||
existing.request.missionId !== incoming.request.missionId ||
|
||||
existing.scope.actorId !== incoming.scope.actorId ||
|
||||
existing.scope.tenantId !== incoming.scope.tenantId ||
|
||||
existing.scope.correlationId !== incoming.scope.correlationId ||
|
||||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
|
||||
) {
|
||||
throw new InMemoryMosCoordinationError(
|
||||
'conflict',
|
||||
'Handoff ID is already bound to different immutable input',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
||||
|
||||
export class InMemoryMosCoordinationError extends Error {
|
||||
constructor(
|
||||
readonly code: InMemoryMosCoordinationErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = InMemoryMosCoordinationError.name;
|
||||
}
|
||||
}
|
||||
|
||||
function activity(
|
||||
status: MosHandoffStatus,
|
||||
summary: string,
|
||||
now: () => Date,
|
||||
): MosCoordinationActivity {
|
||||
return { occurredAt: now().toISOString(), status, summary };
|
||||
}
|
||||
|
||||
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
|
||||
return Object.freeze({
|
||||
handoffId: handoff.handoffId,
|
||||
targetAgentId: handoff.targetAgentId,
|
||||
request: Object.freeze({ ...handoff.request }),
|
||||
scope: Object.freeze({ ...handoff.scope }),
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,23 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss
|
||||
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
||||
export { runTask, resumeTask } from './runner.js';
|
||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||
export {
|
||||
InMemoryMosCoordinationError,
|
||||
InMemoryMosCoordinationPort,
|
||||
} from './in-memory-mos-coordination-port.js';
|
||||
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
|
||||
export type {
|
||||
CoordinationObservation,
|
||||
CoordinationResult,
|
||||
CoordinationScope,
|
||||
MosCoordinationActivity,
|
||||
MosCoordinationIdentity,
|
||||
MosCoordinationPort,
|
||||
MosHandoff,
|
||||
MosHandoffReceipt,
|
||||
MosHandoffRequest,
|
||||
MosHandoffStatus,
|
||||
} from './mos-coordination.js';
|
||||
export type {
|
||||
CreateMissionOptions,
|
||||
Mission,
|
||||
|
||||
207
packages/coord/src/mos-coordination.ts
Normal file
207
packages/coord/src/mos-coordination.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CoordinationScope {
|
||||
readonly actorId: string;
|
||||
readonly tenantId: string;
|
||||
readonly correlationId: string;
|
||||
/** Trusted gateway/configuration identity; never supplied by a channel client. */
|
||||
readonly requesterAgentId: string;
|
||||
}
|
||||
|
||||
export interface MosCoordinationIdentity {
|
||||
readonly interactionAgentId: string;
|
||||
readonly orchestrationAgentId: string;
|
||||
}
|
||||
|
||||
export interface MosHandoffRequest {
|
||||
readonly idempotencyKey: string;
|
||||
readonly summary: string;
|
||||
readonly context?: string;
|
||||
readonly missionId?: string;
|
||||
}
|
||||
|
||||
export interface MosHandoff {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly request: MosHandoffRequest;
|
||||
readonly scope: CoordinationScope;
|
||||
}
|
||||
|
||||
export interface MosHandoffReceipt {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'queued' | 'accepted';
|
||||
readonly correlationId: string;
|
||||
}
|
||||
|
||||
export interface MosCoordinationActivity {
|
||||
readonly occurredAt: string;
|
||||
readonly status: MosHandoffStatus;
|
||||
readonly summary: string;
|
||||
}
|
||||
|
||||
export interface CoordinationObservation {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: MosHandoffStatus;
|
||||
readonly correlationId: string;
|
||||
readonly activity: readonly MosCoordinationActivity[];
|
||||
}
|
||||
|
||||
export interface CoordinationResult {
|
||||
readonly handoffId: string;
|
||||
readonly targetAgentId: string;
|
||||
readonly status: 'completed' | 'failed' | 'pending';
|
||||
readonly correlationId: string;
|
||||
readonly summary?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport-neutral boundary. The interaction plane can request work and read
|
||||
* its progress/result, but it cannot issue worker, review, merge, or other
|
||||
* general orchestration commands.
|
||||
*/
|
||||
export interface MosCoordinationPort {
|
||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
||||
}
|
||||
|
||||
export type MosCoordinationAuthorityErrorCode =
|
||||
| 'invalid_identity'
|
||||
| 'requester_forbidden'
|
||||
| 'target_drift'
|
||||
| 'correlation_drift';
|
||||
|
||||
export class MosCoordinationAuthorityError extends Error {
|
||||
constructor(
|
||||
readonly code: MosCoordinationAuthorityErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = MosCoordinationAuthorityError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces the interaction-to-orchestration authority boundary before a
|
||||
* transport is reached. Identity names remain configuration data.
|
||||
*/
|
||||
export class MosCoordinationClient {
|
||||
private readonly identity: MosCoordinationIdentity;
|
||||
|
||||
constructor(
|
||||
identity: MosCoordinationIdentity,
|
||||
private readonly port: MosCoordinationPort,
|
||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
||||
) {
|
||||
this.identity = normalizeIdentity(identity);
|
||||
}
|
||||
|
||||
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
|
||||
this.assertRequester(scope);
|
||||
const handoff: MosHandoff = {
|
||||
handoffId: this.handoffIdFactory(),
|
||||
targetAgentId: this.identity.orchestrationAgentId,
|
||||
request: snapshotRequest(request),
|
||||
scope: snapshotScope(scope),
|
||||
};
|
||||
const receipt = await this.port.handoff(handoff);
|
||||
if (
|
||||
receipt.handoffId !== handoff.handoffId ||
|
||||
receipt.targetAgentId !== handoff.targetAgentId
|
||||
) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Mos coordination transport returned a mismatched handoff target',
|
||||
);
|
||||
}
|
||||
if (receipt.correlationId !== handoff.scope.correlationId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Mos coordination transport returned a mismatched correlation ID',
|
||||
);
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation> {
|
||||
this.assertRequester(scope);
|
||||
return this.assertObservation(await this.port.observe(handoffId, snapshotScope(scope)), scope);
|
||||
}
|
||||
|
||||
async result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult> {
|
||||
this.assertRequester(scope);
|
||||
return this.assertResult(await this.port.result(handoffId, snapshotScope(scope)), scope);
|
||||
}
|
||||
|
||||
private assertRequester(scope: CoordinationScope): void {
|
||||
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'requester_forbidden',
|
||||
'Requester is not the configured interaction agent',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private assertObservation(
|
||||
observation: CoordinationObservation,
|
||||
scope: CoordinationScope,
|
||||
): CoordinationObservation {
|
||||
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Mos coordination transport returned an unexpected observation target',
|
||||
);
|
||||
}
|
||||
if (observation.correlationId !== scope.correlationId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Mos coordination transport returned a mismatched observation correlation ID',
|
||||
);
|
||||
}
|
||||
return observation;
|
||||
}
|
||||
|
||||
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
|
||||
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'target_drift',
|
||||
'Mos coordination transport returned an unexpected result target',
|
||||
);
|
||||
}
|
||||
if (result.correlationId !== scope.correlationId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'correlation_drift',
|
||||
'Mos coordination transport returned a mismatched result correlation ID',
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
|
||||
const interactionAgentId = identity.interactionAgentId.trim();
|
||||
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
||||
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'invalid_identity',
|
||||
'Interaction and orchestration identities are required',
|
||||
);
|
||||
}
|
||||
if (interactionAgentId === orchestrationAgentId) {
|
||||
throw new MosCoordinationAuthorityError(
|
||||
'invalid_identity',
|
||||
'Interaction and orchestration identities must differ',
|
||||
);
|
||||
}
|
||||
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
||||
}
|
||||
|
||||
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
|
||||
return Object.freeze({ ...request });
|
||||
}
|
||||
|
||||
function snapshotScope(scope: CoordinationScope): CoordinationScope {
|
||||
return Object.freeze({ ...scope });
|
||||
}
|
||||
71
packages/db/drizzle/0012_interaction_durable_state.sql
Normal file
71
packages/db/drizzle/0012_interaction_durable_state.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
CREATE TYPE "public"."interaction_handoff_status" AS ENUM('pending', 'accepted');--> statement-breakpoint
|
||||
CREATE TYPE "public"."interaction_inbox_status" AS ENUM('pending', 'processing', 'processed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."interaction_outbox_status" AS ENUM('pending', 'processing', 'delivered');--> statement-breakpoint
|
||||
CREATE TABLE "interaction_checkpoints" (
|
||||
"session_id" text PRIMARY KEY NOT NULL,
|
||||
"checkpoint_id" text NOT NULL,
|
||||
"cursor" text NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"compaction_epoch" integer NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "interaction_checkpoints_checkpoint_id_unique" UNIQUE("checkpoint_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interaction_handoffs" (
|
||||
"handoff_id" text PRIMARY KEY NOT NULL,
|
||||
"session_id" text NOT NULL,
|
||||
"destination" text NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"checkpoint_id" text NOT NULL,
|
||||
"status" "interaction_handoff_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interaction_inbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" text NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"content_digest" text NOT NULL,
|
||||
"status" "interaction_inbox_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interaction_outbox" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"session_id" text NOT NULL,
|
||||
"idempotency_key" text NOT NULL,
|
||||
"correlation_id" text NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"content_digest" text NOT NULL,
|
||||
"status" "interaction_outbox_status" DEFAULT 'pending' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "interaction_sessions" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"agent_name" text NOT NULL,
|
||||
"tenant_id" text NOT NULL,
|
||||
"owner_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"runtime_session_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "interaction_checkpoints" ADD CONSTRAINT "interaction_checkpoints_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interaction_handoffs" ADD CONSTRAINT "interaction_handoffs_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interaction_inbox" ADD CONSTRAINT "interaction_inbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interaction_outbox" ADD CONSTRAINT "interaction_outbox_session_id_interaction_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."interaction_sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "interaction_sessions" ADD CONSTRAINT "interaction_sessions_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "interaction_handoffs_session_status_idx" ON "interaction_handoffs" USING btree ("session_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "interaction_inbox_session_idempotency_idx" ON "interaction_inbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX "interaction_inbox_session_status_created_idx" ON "interaction_inbox" USING btree ("session_id","status","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "interaction_outbox_session_idempotency_idx" ON "interaction_outbox" USING btree ("session_id","idempotency_key");--> statement-breakpoint
|
||||
CREATE INDEX "interaction_outbox_session_status_created_idx" ON "interaction_outbox" USING btree ("session_id","status","created_at");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_checkpoint_id_unique";--> statement-breakpoint
|
||||
ALTER TABLE "interaction_checkpoints" DROP CONSTRAINT "interaction_checkpoints_pkey";--> statement-breakpoint
|
||||
ALTER TABLE "interaction_checkpoints" ADD COLUMN "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "interaction_checkpoints_session_idempotency_idx" ON "interaction_checkpoints" USING btree ("session_id","checkpoint_id");--> statement-breakpoint
|
||||
CREATE INDEX "interaction_checkpoints_session_epoch_idx" ON "interaction_checkpoints" USING btree ("session_id","compaction_epoch");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "interaction_outbox" ADD COLUMN "channel_id" text;
|
||||
--> statement-breakpoint
|
||||
UPDATE "interaction_outbox" SET "channel_id" = 'legacy:unknown' WHERE "channel_id" IS NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "interaction_outbox" ALTER COLUMN "channel_id" SET NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE "interaction_checkpoints" ADD COLUMN "content_digest" text;--> statement-breakpoint
|
||||
UPDATE "interaction_checkpoints" SET "content_digest" = 'legacy' WHERE "content_digest" IS NULL;--> statement-breakpoint
|
||||
ALTER TABLE "interaction_checkpoints" ALTER COLUMN "content_digest" SET NOT NULL;
|
||||
4172
packages/db/drizzle/meta/0012_snapshot.json
Normal file
4172
packages/db/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4214
packages/db/drizzle/meta/0013_snapshot.json
Normal file
4214
packages/db/drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4220
packages/db/drizzle/meta/0014_snapshot.json
Normal file
4220
packages/db/drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
4244
packages/db/drizzle/meta/0015_snapshot.json
Normal file
4244
packages/db/drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,34 @@
|
||||
"when": 1782310438919,
|
||||
"tag": "0011_bitter_gateway",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1783911983447,
|
||||
"tag": "0012_interaction_durable_state",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1783913232578,
|
||||
"tag": "0013_interaction_checkpoint_history",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1783913398006,
|
||||
"tag": "0014_interaction_outbox_channel_scope",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1783942610000,
|
||||
"tag": "0015_interaction_checkpoint_payload_digest",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -487,6 +487,120 @@ export const agentLogs = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// ─── Tess durable session state ─────────────────────────────────────────────
|
||||
// PostgreSQL is canonical for restart-safe Tess session recovery. The state
|
||||
// machine lives in @mosaicstack/agent; these records are its durable adapter.
|
||||
|
||||
export const interactionInboxStatusEnum = pgEnum('interaction_inbox_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'processed',
|
||||
]);
|
||||
export const interactionOutboxStatusEnum = pgEnum('interaction_outbox_status', [
|
||||
'pending',
|
||||
'processing',
|
||||
'delivered',
|
||||
]);
|
||||
export const interactionHandoffStatusEnum = pgEnum('interaction_handoff_status', [
|
||||
'pending',
|
||||
'accepted',
|
||||
]);
|
||||
|
||||
export const interactionSessions = pgTable('interaction_sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
agentName: text('agent_name').notNull(),
|
||||
tenantId: text('tenant_id').notNull(),
|
||||
ownerId: text('owner_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
providerId: text('provider_id').notNull(),
|
||||
runtimeSessionId: text('runtime_session_id').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const interactionInbox = pgTable(
|
||||
'interaction_inbox',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sessionId: text('session_id')
|
||||
.notNull()
|
||||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
content: text('content').notNull(),
|
||||
contentDigest: text('content_digest').notNull(),
|
||||
status: interactionInboxStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('interaction_inbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
|
||||
index('interaction_inbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const interactionOutbox = pgTable(
|
||||
'interaction_outbox',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sessionId: text('session_id')
|
||||
.notNull()
|
||||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||
idempotencyKey: text('idempotency_key').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
channelId: text('channel_id').notNull(),
|
||||
kind: text('kind').notNull(),
|
||||
content: text('content').notNull(),
|
||||
contentDigest: text('content_digest').notNull(),
|
||||
status: interactionOutboxStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('interaction_outbox_session_idempotency_idx').on(t.sessionId, t.idempotencyKey),
|
||||
index('interaction_outbox_session_status_created_idx').on(t.sessionId, t.status, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const interactionCheckpoints = pgTable(
|
||||
'interaction_checkpoints',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
sessionId: text('session_id')
|
||||
.notNull()
|
||||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||
checkpointId: text('checkpoint_id').notNull(),
|
||||
contentDigest: text('content_digest').notNull(),
|
||||
cursor: text('cursor').notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
compactionEpoch: integer('compaction_epoch').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('interaction_checkpoints_session_idempotency_idx').on(t.sessionId, t.checkpointId),
|
||||
index('interaction_checkpoints_session_epoch_idx').on(t.sessionId, t.compactionEpoch),
|
||||
],
|
||||
);
|
||||
|
||||
export const interactionHandoffs = pgTable(
|
||||
'interaction_handoffs',
|
||||
{
|
||||
handoffId: text('handoff_id').primaryKey(),
|
||||
sessionId: text('session_id')
|
||||
.notNull()
|
||||
.references(() => interactionSessions.id, { onDelete: 'cascade' }),
|
||||
destination: text('destination').notNull(),
|
||||
correlationId: text('correlation_id').notNull(),
|
||||
checkpointId: text('checkpoint_id').notNull(),
|
||||
status: interactionHandoffStatusEnum('status').notNull().default('pending'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [index('interaction_handoffs_session_status_idx').on(t.sessionId, t.status)],
|
||||
);
|
||||
|
||||
// ─── Skills ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const skills = pgTable(
|
||||
|
||||
@@ -274,6 +274,20 @@ describe('KeywordAdapter', () => {
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return all scoped insights for the explicit wildcard query', async () => {
|
||||
await adapter.storeInsight({
|
||||
userId: 'u1',
|
||||
content: 'A literal * marker is still ordinary content',
|
||||
source: 'chat',
|
||||
category: 'technical',
|
||||
relevanceScore: 0.7,
|
||||
});
|
||||
|
||||
const results = await adapter.searchInsights('u1', '*');
|
||||
expect(results).toHaveLength(4);
|
||||
expect(results.every((result) => result.score === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty for empty query', async () => {
|
||||
const results = await adapter.searchInsights('u1', ' ');
|
||||
expect(results).toHaveLength(0);
|
||||
|
||||
@@ -132,19 +132,23 @@ export class KeywordAdapter implements MemoryAdapter {
|
||||
opts?: { limit?: number; embedding?: number[] },
|
||||
): Promise<InsightSearchResult[]> {
|
||||
const limit = opts?.limit ?? 10;
|
||||
const words = query
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0);
|
||||
const normalizedQuery = query.trim();
|
||||
const matchAll = normalizedQuery === '*';
|
||||
const words = matchAll
|
||||
? []
|
||||
: normalizedQuery
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter((word) => word.length > 0);
|
||||
|
||||
if (words.length === 0) return [];
|
||||
if (words.length === 0 && !matchAll) return [];
|
||||
|
||||
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId });
|
||||
|
||||
const scored: InsightSearchResult[] = [];
|
||||
for (const row of rows) {
|
||||
const content = row.content.toLowerCase();
|
||||
let score = 0;
|
||||
let score = matchAll ? 1 : 0;
|
||||
for (const word of words) {
|
||||
if (content.includes(word)) score++;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,13 @@ export type {
|
||||
InsightSearchResult,
|
||||
} from './types.js';
|
||||
export { createMemoryAdapter, registerMemoryAdapter } from './factory.js';
|
||||
export {
|
||||
createOperatorMemoryPlugin,
|
||||
type OperatorMemoryPlugin,
|
||||
type OperatorMemoryScope,
|
||||
type OperatorMemoryConfig,
|
||||
type OperatorMemoryResult,
|
||||
} from './operator-memory-plugin.js';
|
||||
export { PgVectorAdapter } from './adapters/pgvector.js';
|
||||
export { KeywordAdapter } from './adapters/keyword.js';
|
||||
|
||||
|
||||
147
packages/memory/src/operator-memory-plugin.test.ts
Normal file
147
packages/memory/src/operator-memory-plugin.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createOperatorMemoryPlugin } from './operator-memory-plugin.js';
|
||||
import type { Insight, InsightSearchResult, NewInsight } from './types.js';
|
||||
|
||||
function adapter() {
|
||||
return {
|
||||
name: 'test',
|
||||
embedder: null,
|
||||
storeInsight: vi.fn(
|
||||
async (value: NewInsight): Promise<Insight> => ({
|
||||
...value,
|
||||
id: '1',
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
),
|
||||
searchInsights: vi.fn(async (): Promise<InsightSearchResult[]> => []),
|
||||
getInsight: vi.fn(),
|
||||
deleteInsight: vi.fn(),
|
||||
getPreference: vi.fn(),
|
||||
setPreference: vi.fn(),
|
||||
deletePreference: vi.fn(),
|
||||
listPreferences: vi.fn(),
|
||||
close: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('OperatorMemoryPlugin', () => {
|
||||
it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => {
|
||||
const memory = adapter();
|
||||
const plugin = createOperatorMemoryPlugin({
|
||||
adapter: memory,
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
redact: (value) => value,
|
||||
});
|
||||
await plugin.capture(
|
||||
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
|
||||
{ content: 'one', source: 'test', category: 'note' },
|
||||
);
|
||||
await plugin.capture(
|
||||
{ tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' },
|
||||
{ content: 'two', source: 'test', category: 'note' },
|
||||
);
|
||||
await plugin.capture(
|
||||
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' },
|
||||
{ content: 'three', source: 'test', category: 'note' },
|
||||
);
|
||||
|
||||
expect(
|
||||
memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId),
|
||||
).toEqual([
|
||||
'["operator-memory","tenant-a","owner-a","session-a"]',
|
||||
'["operator-memory","tenant-b","owner-a","session-a"]',
|
||||
'["operator-memory","tenant-a","owner-a","session-b"]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an incomplete runtime scope before it can produce a shared storage key', async () => {
|
||||
const memory = adapter();
|
||||
const plugin = createOperatorMemoryPlugin({
|
||||
adapter: memory,
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
redact: (value) => value,
|
||||
});
|
||||
|
||||
await expect(
|
||||
plugin.capture(
|
||||
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: ' ' },
|
||||
{ content: 'note', source: 'test', category: 'note' },
|
||||
),
|
||||
).rejects.toThrow('Operator memory session ID is required');
|
||||
expect(memory.storeInsight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses a differently named configured instance in retrieval provenance', async () => {
|
||||
const memory = adapter();
|
||||
memory.searchInsights.mockResolvedValue([
|
||||
{ id: '1', content: 'x', score: 1, metadata: { source: 'project' } },
|
||||
]);
|
||||
const plugin = createOperatorMemoryPlugin({
|
||||
adapter: memory,
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
redact: (value) => value,
|
||||
});
|
||||
|
||||
const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x');
|
||||
|
||||
expect(results[0]?.provenance).toEqual({
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
source: 'project',
|
||||
});
|
||||
});
|
||||
|
||||
it('orders startup context with project and flat-file truth before retrieved material', async () => {
|
||||
const memory = adapter();
|
||||
memory.searchInsights.mockResolvedValue([
|
||||
{ id: 'retrieval', content: 'retrieval', score: 1 },
|
||||
{ id: 'flat-file', content: 'flat-file', score: 1, metadata: { source: 'flat-file' } },
|
||||
{ id: 'project', content: 'project', score: 1, metadata: { source: 'project' } },
|
||||
]);
|
||||
const plugin = createOperatorMemoryPlugin({
|
||||
adapter: memory,
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
maxStartupContext: 2,
|
||||
redact: (value) => value,
|
||||
});
|
||||
|
||||
const context = await plugin.startupContext({
|
||||
tenantId: 'tenant-a',
|
||||
ownerId: 'owner-a',
|
||||
sessionId: 'session-a',
|
||||
});
|
||||
|
||||
expect(context.map((result) => result.id)).toEqual(['project', 'flat-file']);
|
||||
expect(memory.searchInsights).toHaveBeenCalledWith(expect.any(String), '*', { limit: 64 });
|
||||
});
|
||||
|
||||
it('redacts content before adapter persistence and records configured provenance metadata', async () => {
|
||||
const memory = adapter();
|
||||
const plugin = createOperatorMemoryPlugin({
|
||||
adapter: memory,
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
redact: (value) => value.replace('secret', '[REDACTED]'),
|
||||
});
|
||||
|
||||
await plugin.capture(
|
||||
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
|
||||
{ content: 'secret note', source: 'project', category: 'note' },
|
||||
);
|
||||
|
||||
expect(memory.storeInsight).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: '[REDACTED] note',
|
||||
metadata: {
|
||||
instanceId: 'Nova',
|
||||
namespace: 'operator-memory',
|
||||
source: 'project',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
154
packages/memory/src/operator-memory-plugin.ts
Normal file
154
packages/memory/src/operator-memory-plugin.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js';
|
||||
|
||||
const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64;
|
||||
|
||||
/** Immutable server-derived boundary; callers never choose an adapter namespace. */
|
||||
export interface OperatorMemoryScope {
|
||||
readonly tenantId: string;
|
||||
readonly ownerId: string;
|
||||
readonly sessionId: string;
|
||||
}
|
||||
|
||||
export interface OperatorMemoryConfig {
|
||||
/** Adapter injection is deployment/lifecycle configuration, never caller input. */
|
||||
readonly adapter: MemoryAdapter;
|
||||
/** Configured agent identity; it is metadata rather than a storage key default. */
|
||||
readonly instanceId: string;
|
||||
/** Configured storage partition; callers cannot select a namespace. */
|
||||
readonly namespace: string;
|
||||
readonly maxStartupContext?: number;
|
||||
redact(content: string): string;
|
||||
}
|
||||
|
||||
export interface OperatorMemoryResult extends InsightSearchResult {
|
||||
provenance: { instanceId: string; namespace: string; source: string };
|
||||
}
|
||||
|
||||
export interface OperatorMemoryPlugin {
|
||||
capture(
|
||||
scope: OperatorMemoryScope,
|
||||
input: { content: string; source: string; category: string },
|
||||
): Promise<Insight>;
|
||||
search(
|
||||
scope: OperatorMemoryScope,
|
||||
query: string,
|
||||
limit?: number,
|
||||
): Promise<OperatorMemoryResult[]>;
|
||||
recent(scope: OperatorMemoryScope, limit?: number): Promise<OperatorMemoryResult[]>;
|
||||
stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>;
|
||||
startupContext(scope: OperatorMemoryScope): Promise<OperatorMemoryResult[]>;
|
||||
}
|
||||
|
||||
function scopedUserId(scope: OperatorMemoryScope, namespace: string): string {
|
||||
const normalizedScope = normalizeScope(scope);
|
||||
// JSON tuple encoding avoids delimiter collisions between independently scoped IDs.
|
||||
return JSON.stringify([
|
||||
namespace,
|
||||
normalizedScope.tenantId,
|
||||
normalizedScope.ownerId,
|
||||
normalizedScope.sessionId,
|
||||
]);
|
||||
}
|
||||
|
||||
function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope {
|
||||
if (typeof scope !== 'object' || scope === null) {
|
||||
throw new Error('Operator memory scope is required');
|
||||
}
|
||||
return Object.freeze({
|
||||
tenantId: requiredScopeId(scope.tenantId, 'tenant ID'),
|
||||
ownerId: requiredScopeId(scope.ownerId, 'owner ID'),
|
||||
sessionId: requiredScopeId(scope.sessionId, 'session ID'),
|
||||
});
|
||||
}
|
||||
|
||||
function requiredScopeId(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`Operator memory ${field} is required`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number {
|
||||
return (
|
||||
startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source)
|
||||
);
|
||||
}
|
||||
|
||||
function startupSourcePriority(source: string): number {
|
||||
if (source === 'project') return 0;
|
||||
if (source === 'flat-file') return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig {
|
||||
const instanceId = config.instanceId.trim();
|
||||
const namespace = config.namespace.trim();
|
||||
const maxStartupContext = config.maxStartupContext ?? 8;
|
||||
if (instanceId.length === 0 || namespace.length === 0) {
|
||||
throw new Error('Operator memory instance ID and namespace must be configured');
|
||||
}
|
||||
if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) {
|
||||
throw new Error('Operator memory startup context limit must be a positive integer');
|
||||
}
|
||||
return Object.freeze({ ...config, instanceId, namespace, maxStartupContext });
|
||||
}
|
||||
|
||||
/** Creates a leaf-package, replaceable memory adapter facade. */
|
||||
export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin {
|
||||
const pluginConfig = normalizeConfig(config);
|
||||
const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({
|
||||
...result,
|
||||
provenance: {
|
||||
instanceId: pluginConfig.instanceId,
|
||||
namespace: pluginConfig.namespace,
|
||||
source: String(result.metadata?.['source'] ?? 'retrieval'),
|
||||
},
|
||||
});
|
||||
const search = async (
|
||||
scope: OperatorMemoryScope,
|
||||
query: string,
|
||||
limit = 10,
|
||||
): Promise<OperatorMemoryResult[]> =>
|
||||
(
|
||||
await pluginConfig.adapter.searchInsights(
|
||||
scopedUserId(scope, pluginConfig.namespace),
|
||||
query,
|
||||
{
|
||||
limit,
|
||||
},
|
||||
)
|
||||
).map(mapResult);
|
||||
return {
|
||||
async capture(scope, input) {
|
||||
return pluginConfig.adapter.storeInsight({
|
||||
userId: scopedUserId(scope, pluginConfig.namespace),
|
||||
content: pluginConfig.redact(input.content),
|
||||
source: input.source,
|
||||
category: input.category,
|
||||
relevanceScore: 1,
|
||||
metadata: {
|
||||
namespace: pluginConfig.namespace,
|
||||
instanceId: pluginConfig.instanceId,
|
||||
source: input.source,
|
||||
},
|
||||
});
|
||||
},
|
||||
search,
|
||||
async recent(scope, limit = 10) {
|
||||
return search(scope, '*', limit);
|
||||
},
|
||||
async stats(scope) {
|
||||
return {
|
||||
namespace: pluginConfig.namespace,
|
||||
resultCount: (await search(scope, '*', 100)).length,
|
||||
};
|
||||
},
|
||||
async startupContext(scope) {
|
||||
const maxStartupContext = pluginConfig.maxStartupContext ?? 8;
|
||||
// Prioritize authoritative sources within a bounded candidate window.
|
||||
const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT);
|
||||
const context = await search(scope, '*', candidateLimit);
|
||||
return [...context].sort(compareStartupContext).slice(0, maxStartupContext);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -49,6 +49,10 @@ export interface MemoryAdapter {
|
||||
// Insights
|
||||
storeInsight(insight: NewInsight): Promise<Insight>;
|
||||
getInsight(id: string): Promise<Insight | null>;
|
||||
/**
|
||||
* Searches within one scoped user ID. The reserved `*` query returns scoped
|
||||
* recent/all results rather than performing backend-specific wildcard parsing.
|
||||
*/
|
||||
searchInsights(
|
||||
userId: string,
|
||||
query: string,
|
||||
|
||||
@@ -15,6 +15,21 @@ default tmux server.
|
||||
|
||||
- `examples/minimal.yaml` starts one local canary slot.
|
||||
- `examples/local-canary.yaml` starts a small generic dogfood fleet.
|
||||
- `examples/operator-interaction.yaml` is an example Pi operator-interaction
|
||||
service; replace its example agent name before provisioning.
|
||||
|
||||
## Operator interaction service
|
||||
|
||||
`services/operator-interaction.yaml` pins the Pi runtime, GPT-5.6 Sol model,
|
||||
high reasoning, and the `operator-interaction` tool policy. The agent identity
|
||||
is provisioning data: choose a roster name, generate its per-agent environment
|
||||
file, then start the matching generic systemd instance. The service fails before
|
||||
launch if the configured identity does not match the instance or any pinned
|
||||
policy field drifts.
|
||||
|
||||
The installed `tools/fleet/print-interaction-effective-policy.sh` prints only
|
||||
the resolved name, runtime, model, reasoning, and tool policy. It never reads
|
||||
or prints credential variables.
|
||||
|
||||
Initialize a roster:
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Example instance only. Replace `Tess` with the chosen provisioned identity.
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-fleet
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~/src
|
||||
runtimes:
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: Tess
|
||||
runtime: pi
|
||||
class: operator-interaction
|
||||
model_hint: openai/gpt-5.6-sol
|
||||
reasoning_level: high
|
||||
tool_policy: operator-interaction
|
||||
persistent_persona: true
|
||||
@@ -0,0 +1,11 @@
|
||||
# Operator Interaction — fleet role definition
|
||||
|
||||
The **operator-interaction** role is the authorized human interaction plane for
|
||||
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||
hands coding or general orchestration work to Mos.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- It does not claim Mos-owned coding or general orchestration work.
|
||||
- It exposes only the configured, observable tool policy.
|
||||
- It does not receive or surface credentials in its effective policy.
|
||||
@@ -105,6 +105,18 @@
|
||||
"modelHint": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoning_level": {
|
||||
"type": "string"
|
||||
},
|
||||
"reasoningLevel": {
|
||||
"type": "string"
|
||||
},
|
||||
"tool_policy": {
|
||||
"type": "string"
|
||||
},
|
||||
"toolPolicy": {
|
||||
"type": "string"
|
||||
},
|
||||
"persistent_persona": {
|
||||
"oneOf": [{ "type": "boolean" }, { "type": "string" }]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Generic service policy. Provisioning supplies the agent name as data.
|
||||
runtime: pi
|
||||
model: openai/gpt-5.6-sol
|
||||
reasoning: high
|
||||
tool_policy: operator-interaction
|
||||
@@ -12,6 +12,8 @@ exact-match session.
|
||||
|
||||
- `mosaic-tmux-holder.service` — user-mode holder that owns the named tmux server.
|
||||
- `mosaic-agent@.service` — user-mode template for one reusable agent session.
|
||||
- `mosaic-interaction-agent@.service` — generic Pi operator-interaction template
|
||||
that fails fast when its pinned runtime policy is incomplete or changed.
|
||||
- `test-fleet-units.sh` — validates unit syntax and required relationships.
|
||||
|
||||
The agent template calls:
|
||||
@@ -45,12 +47,22 @@ MOSAIC_AGENT_WORKDIR=$HOME/src/your-project
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user ~/.config/mosaic/tools/fleet ~/.config/mosaic/fleet/agents
|
||||
cp packages/mosaic/framework/systemd/user/mosaic-*.service ~/.config/systemd/user/
|
||||
cp packages/mosaic/framework/tools/fleet/start-agent-session.sh ~/.config/mosaic/tools/fleet/
|
||||
chmod +x ~/.config/mosaic/tools/fleet/start-agent-session.sh
|
||||
cp packages/mosaic/framework/tools/fleet/*.sh ~/.config/mosaic/tools/fleet/
|
||||
chmod +x ~/.config/mosaic/tools/fleet/*.sh
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user start mosaic-tmux-holder.service
|
||||
systemctl --user start mosaic-agent@canary.service
|
||||
tmux -L mosaic-fleet ls
|
||||
|
||||
# For an operator-interaction service, the roster/env identity selects the
|
||||
# generic unit instance; no service source is renamed for an instance.
|
||||
systemctl --user start mosaic-interaction-agent@<agent-name>.service
|
||||
MOSAIC_AGENT_NAME=<agent-name> \
|
||||
MOSAIC_AGENT_RUNTIME=pi \
|
||||
MOSAIC_AGENT_MODEL=openai/gpt-5.6-sol \
|
||||
MOSAIC_AGENT_REASONING=high \
|
||||
MOSAIC_AGENT_TOOL_POLICY=operator-interaction \
|
||||
~/.config/mosaic/tools/fleet/print-interaction-effective-policy.sh
|
||||
```
|
||||
|
||||
Do not use `tmux kill-server` without `-L mosaic-fleet`; this pattern is meant
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=Mosaic operator interaction agent %i
|
||||
Documentation=https://git.mosaicstack.dev/mosaicstack/stack
|
||||
Requires=mosaic-tmux-holder.service
|
||||
After=mosaic-tmux-holder.service
|
||||
PartOf=mosaic-tmux-holder.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
Environment=MOSAIC_AGENT_NAME=%i
|
||||
EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env
|
||||
ExecStart=/bin/bash %h/.config/mosaic/tools/fleet/start-interaction-service.sh %i
|
||||
ExecStop=-/bin/bash -lc 'if [ -n "${MOSAIC_TMUX_SOCKET:-}" ]; then tmux -L "$MOSAIC_TMUX_SOCKET" kill-session -t "=%i"; else tmux kill-session -t "=%i"; fi'
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -4,6 +4,7 @@ set -euo pipefail
|
||||
SCRIPT_DIR=$(cd -- "$(dirname -- "$0")" && pwd)
|
||||
HOLDER="$SCRIPT_DIR/mosaic-tmux-holder.service"
|
||||
AGENT="$SCRIPT_DIR/mosaic-agent@.service"
|
||||
INTERACTION="$SCRIPT_DIR/mosaic-interaction-agent@.service"
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
@@ -12,6 +13,7 @@ fail() {
|
||||
|
||||
[ -f "$HOLDER" ] || fail "missing mosaic-tmux-holder.service"
|
||||
[ -f "$AGENT" ] || fail "missing mosaic-agent@.service"
|
||||
[ -f "$INTERACTION" ] || fail "missing mosaic-interaction-agent@.service"
|
||||
|
||||
grep -qF 'ExecStart=' "$HOLDER" || fail "holder has no ExecStart"
|
||||
grep -qF 'tmux -L' "$HOLDER" || fail "holder does not use named tmux socket"
|
||||
@@ -19,9 +21,12 @@ grep -qF '_holder' "$HOLDER" || fail "holder session is not explicit"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$AGENT" || fail "agent does not require holder"
|
||||
grep -qF 'start-agent-session.sh' "$AGENT" || fail "agent unit does not call start-agent-session.sh"
|
||||
grep -qF 'kill-session -t "=%i"' "$AGENT" || fail "agent stop does not exact-match its session"
|
||||
grep -qF 'Requires=mosaic-tmux-holder.service' "$INTERACTION" || fail "interaction service does not require holder"
|
||||
grep -qF 'EnvironmentFile=%h/.config/mosaic/fleet/agents/%i.env' "$INTERACTION" || fail "interaction service does not require per-agent config"
|
||||
grep -qF 'start-interaction-service.sh %i' "$INTERACTION" || fail "interaction service does not validate before startup"
|
||||
|
||||
if command -v systemd-analyze >/dev/null 2>&1; then
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
systemd-analyze verify --user "$HOLDER" "$AGENT" "$INTERACTION" >/tmp/mosaic-fleet-systemd-verify.log 2>&1 || {
|
||||
cat /tmp/mosaic-fleet-systemd-verify.log >&2
|
||||
fail "systemd-analyze verify failed"
|
||||
}
|
||||
|
||||
18
packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh
Executable file
18
packages/mosaic/framework/tools/fleet/print-interaction-effective-policy.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${MOSAIC_AGENT_NAME:-}
|
||||
RUNTIME=${MOSAIC_AGENT_RUNTIME:-}
|
||||
MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
TOOL_POLICY=${MOSAIC_AGENT_TOOL_POLICY:-}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || { echo 'ERROR: MOSAIC_AGENT_NAME is required' >&2; exit 64; }
|
||||
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo 'ERROR: invalid agent name' >&2; exit 64; }
|
||||
[ "$RUNTIME" = 'pi' ] || { echo 'ERROR: invalid runtime policy' >&2; exit 64; }
|
||||
[ "$MODEL" = 'openai/gpt-5.6-sol' ] || { echo 'ERROR: invalid model policy' >&2; exit 64; }
|
||||
[ "$REASONING" = 'high' ] || { echo 'ERROR: invalid reasoning policy' >&2; exit 64; }
|
||||
[ "$TOOL_POLICY" = 'operator-interaction' ] || { echo 'ERROR: invalid tool policy' >&2; exit 64; }
|
||||
|
||||
printf '{"agentName":"%s","runtime":"%s","model":"%s","reasoning":"%s","toolPolicy":"%s"}\n' \
|
||||
"$AGENT_NAME" "$RUNTIME" "$MODEL" "$REASONING" "$TOOL_POLICY"
|
||||
@@ -8,6 +8,7 @@ AGENT_NAME=${1:-${MOSAIC_AGENT_NAME:-}}
|
||||
MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_MODEL=${MOSAIC_AGENT_MODEL:-}
|
||||
MOSAIC_AGENT_REASONING=${MOSAIC_AGENT_REASONING:-}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||
@@ -18,6 +19,14 @@ if [ -z "$AGENT_NAME" ]; then
|
||||
exit 64
|
||||
fi
|
||||
|
||||
case "$MOSAIC_AGENT_REASONING" in
|
||||
''|low|medium|high) ;;
|
||||
*)
|
||||
echo "ERROR: MOSAIC_AGENT_REASONING must be low, medium, or high" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v tmux >/dev/null 2>&1; then
|
||||
echo "ERROR: tmux is required" >&2
|
||||
exit 69
|
||||
@@ -41,7 +50,7 @@ fi
|
||||
if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
# Map the roster's per-agent model_hint to `--model` so workers launch on the
|
||||
# configured model (e.g. pi on openai-codex/gpt-5.5:high). Omitted when unset.
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}"
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME${MOSAIC_AGENT_MODEL:+ --model $MOSAIC_AGENT_MODEL}${MOSAIC_AGENT_REASONING:+ --thinking $MOSAIC_AGENT_REASONING}"
|
||||
fi
|
||||
|
||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||
@@ -124,11 +133,12 @@ AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
|
||||
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
|
||||
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
|
||||
AGENT_TOOL_POLICY_Q=$(printf '%q' "${MOSAIC_AGENT_TOOL_POLICY:-}")
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
else
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export MOSAIC_AGENT_TOOL_POLICY=${AGENT_TOOL_POLICY_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
|
||||
19
packages/mosaic/framework/tools/fleet/start-interaction-service.sh
Executable file
19
packages/mosaic/framework/tools/fleet/start-interaction-service.sh
Executable file
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
AGENT_NAME=${1:-}
|
||||
|
||||
fail() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 64
|
||||
}
|
||||
|
||||
[ -n "$AGENT_NAME" ] || fail "agent name argument is required"
|
||||
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9_.-]+$ ]] || fail "agent name contains unsupported characters"
|
||||
[ "${MOSAIC_AGENT_NAME:-}" = "$AGENT_NAME" ] || fail "configured agent name must exactly match the service instance"
|
||||
[ "${MOSAIC_AGENT_RUNTIME:-}" = "pi" ] || fail "operator interaction service requires runtime pi"
|
||||
[ "${MOSAIC_AGENT_MODEL:-}" = "openai/gpt-5.6-sol" ] || fail "operator interaction service requires the pinned model"
|
||||
[ "${MOSAIC_AGENT_REASONING:-}" = "high" ] || fail "operator interaction service requires high reasoning"
|
||||
[ "${MOSAIC_AGENT_TOOL_POLICY:-}" = "operator-interaction" ] || fail "operator interaction service requires the operator-interaction tool policy"
|
||||
|
||||
exec "$(cd -- "$(dirname -- "$0")" && pwd)/start-agent-session.sh" "$AGENT_NAME"
|
||||
@@ -105,6 +105,7 @@ MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_AGENT_CLASS="code" \
|
||||
MOSAIC_AGENT_TOOL_POLICY="operator-interaction" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||
@@ -139,6 +140,8 @@ echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
|
||||
echo "$all_args" | grep -qF "export MOSAIC_AGENT_TOOL_POLICY=operator-interaction" || \
|
||||
fail "pane command does not export MOSAIC_AGENT_TOOL_POLICY into the pane"
|
||||
|
||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||
TMUX_ARGS_FILE2=$(mktemp)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { registerQueueCommand } from '@mosaicstack/queue';
|
||||
import { registerStorageCommand } from '@mosaicstack/storage';
|
||||
import { registerTelemetryCommand } from './commands/telemetry.js';
|
||||
import { registerAgentCommand } from './commands/agent.js';
|
||||
import { registerInteractionCommand } from './commands/interaction.js';
|
||||
import { registerConfigCommand } from './commands/config.js';
|
||||
import { registerFleetCommand } from './commands/fleet.js';
|
||||
import { registerMissionCommand } from './commands/mission.js';
|
||||
@@ -352,6 +353,10 @@ registerFederationCommand(program);
|
||||
|
||||
registerAgentCommand(program);
|
||||
|
||||
// ─── interaction ───────────────────────────────────────────────────────
|
||||
|
||||
registerInteractionCommand(program);
|
||||
|
||||
// ─── fleet ─────────────────────────────────────────────────────────────
|
||||
|
||||
registerFleetCommand(program);
|
||||
|
||||
@@ -165,6 +165,19 @@ describe('composeContract — overlay composer', () => {
|
||||
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
||||
});
|
||||
|
||||
it('injects the configured operator-interaction tool policy into the runtime contract', () => {
|
||||
const previous = process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_TOOL_POLICY'] = 'operator-interaction';
|
||||
const out = composeContract('pi', fixture.home);
|
||||
expect(out).toContain('# Fleet Tool Policy (operator-interaction)');
|
||||
expect(out).toContain('Denied by default: coding/general orchestration claims');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['MOSAIC_AGENT_TOOL_POLICY'];
|
||||
else process.env['MOSAIC_AGENT_TOOL_POLICY'] = previous;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Persona contract injection (A3b) ──────────────────────────────────────
|
||||
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
|
||||
// (override-aware). Save/restore the env so these tests don't leak state.
|
||||
|
||||
@@ -87,6 +87,10 @@ interface RawFleetRoster {
|
||||
workingDirectory?: unknown;
|
||||
model_hint?: unknown;
|
||||
modelHint?: unknown;
|
||||
reasoning_level?: unknown;
|
||||
reasoningLevel?: unknown;
|
||||
tool_policy?: unknown;
|
||||
toolPolicy?: unknown;
|
||||
persistent_persona?: unknown;
|
||||
persistentPersona?: unknown;
|
||||
reset_between_tasks?: unknown;
|
||||
@@ -102,6 +106,8 @@ export interface FleetAgent {
|
||||
className: string;
|
||||
workingDirectory?: string;
|
||||
modelHint?: string;
|
||||
reasoningLevel?: string;
|
||||
toolPolicy?: string;
|
||||
persistentPersona?: boolean | string;
|
||||
resetBetweenTasks?: boolean;
|
||||
kickstartTemplate?: string;
|
||||
@@ -510,6 +516,12 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
|
||||
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
|
||||
// openai-codex/gpt-5.5:high). Empty when the agent declares no model_hint.
|
||||
`MOSAIC_AGENT_MODEL=${shellEnvValue(agent.modelHint ?? '')}`,
|
||||
...(agent.reasoningLevel !== undefined
|
||||
? [`MOSAIC_AGENT_REASONING=${shellEnvValue(agent.reasoningLevel)}`]
|
||||
: []),
|
||||
...(agent.toolPolicy !== undefined
|
||||
? [`MOSAIC_AGENT_TOOL_POLICY=${shellEnvValue(agent.toolPolicy)}`]
|
||||
: []),
|
||||
`MOSAIC_AGENT_WORKDIR=${shellEnvValue(expandHome(workingDirectory))}`,
|
||||
`MOSAIC_TMUX_SOCKET=${shellEnvValue(roster.tmux.socketName)}`,
|
||||
'',
|
||||
@@ -2316,13 +2328,35 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
await mkdir(activePaths.agentEnvDir, { recursive: true });
|
||||
|
||||
const startAgentSessionPath = join(activePaths.fleetToolsDir, 'start-agent-session.sh');
|
||||
const startInteractionServicePath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'start-interaction-service.sh',
|
||||
);
|
||||
const printInteractionPolicyPath = join(
|
||||
activePaths.fleetToolsDir,
|
||||
'print-interaction-effective-policy.sh',
|
||||
);
|
||||
const sendMessagePath = join(activePaths.tmuxToolsDir, 'send-message.sh');
|
||||
const agentSendPath = join(activePaths.tmuxToolsDir, 'agent-send.sh');
|
||||
const executableToolPaths = [startAgentSessionPath, sendMessagePath, agentSendPath];
|
||||
const executableToolPaths = [
|
||||
startAgentSessionPath,
|
||||
startInteractionServicePath,
|
||||
printInteractionPolicyPath,
|
||||
sendMessagePath,
|
||||
agentSendPath,
|
||||
];
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-agent-session.sh'),
|
||||
startAgentSessionPath,
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'start-interaction-service.sh'),
|
||||
startInteractionServicePath,
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'tools', 'fleet', 'print-interaction-effective-policy.sh'),
|
||||
printInteractionPolicyPath,
|
||||
);
|
||||
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'send-message.sh'), sendMessagePath);
|
||||
await copyFile(join(frameworkRoot, 'tools', 'tmux', 'agent-send.sh'), agentSendPath);
|
||||
for (const toolPath of executableToolPaths) {
|
||||
@@ -2336,6 +2370,10 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-agent@.service'),
|
||||
join(activePaths.systemdUserDir, 'mosaic-agent@.service'),
|
||||
);
|
||||
await copyFile(
|
||||
join(frameworkRoot, 'systemd', 'user', 'mosaic-interaction-agent@.service'),
|
||||
join(activePaths.systemdUserDir, 'mosaic-interaction-agent@.service'),
|
||||
);
|
||||
|
||||
for (const agent of roster.agents) {
|
||||
const envPath = join(activePaths.agentEnvDir, `${agent.name}.env`);
|
||||
@@ -2462,6 +2500,10 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
||||
'workingDirectory',
|
||||
'model_hint',
|
||||
'modelHint',
|
||||
'reasoning_level',
|
||||
'reasoningLevel',
|
||||
'tool_policy',
|
||||
'toolPolicy',
|
||||
'persistent_persona',
|
||||
'persistentPersona',
|
||||
'reset_between_tasks',
|
||||
@@ -2493,6 +2535,14 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
||||
raw.model_hint ?? raw.modelHint,
|
||||
`Fleet roster agent "${name}" model_hint`,
|
||||
),
|
||||
reasoningLevel: optionalString(
|
||||
raw.reasoning_level ?? raw.reasoningLevel,
|
||||
`Fleet roster agent "${name}" reasoning_level`,
|
||||
),
|
||||
toolPolicy: optionalString(
|
||||
raw.tool_policy ?? raw.toolPolicy,
|
||||
`Fleet roster agent "${name}" tool_policy`,
|
||||
),
|
||||
persistentPersona: optionalBooleanOrString(
|
||||
raw.persistent_persona ?? raw.persistentPersona,
|
||||
`Fleet roster agent "${name}" persistent_persona`,
|
||||
@@ -2783,6 +2833,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
||||
if (agent.modelHint !== undefined) {
|
||||
raw['model_hint'] = agent.modelHint;
|
||||
}
|
||||
if (agent.reasoningLevel !== undefined) {
|
||||
raw['reasoning_level'] = agent.reasoningLevel;
|
||||
}
|
||||
if (agent.toolPolicy !== undefined) {
|
||||
raw['tool_policy'] = agent.toolPolicy;
|
||||
}
|
||||
if (agent.persistentPersona !== undefined) {
|
||||
raw['persistent_persona'] = agent.persistentPersona;
|
||||
}
|
||||
|
||||
23
packages/mosaic/src/commands/interaction.test.ts
Normal file
23
packages/mosaic/src/commands/interaction.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
import { registerInteractionCommand } from './interaction.js';
|
||||
|
||||
describe('generic interaction CLI', (): void => {
|
||||
it('registers every required interaction verb without an instance-specific command name', () => {
|
||||
const program = new Command();
|
||||
const command = registerInteractionCommand(program);
|
||||
expect(command.name()).toBe('interaction');
|
||||
expect(command.commands.map((item) => item.name()).sort()).toEqual([
|
||||
'attach',
|
||||
'chat',
|
||||
'enroll',
|
||||
'health',
|
||||
'recover',
|
||||
'send',
|
||||
'sessions',
|
||||
'status',
|
||||
'stop',
|
||||
'tree',
|
||||
]);
|
||||
});
|
||||
});
|
||||
232
packages/mosaic/src/commands/interaction.ts
Normal file
232
packages/mosaic/src/commands/interaction.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Command } from 'commander';
|
||||
import { withAuth } from './with-auth.js';
|
||||
import {
|
||||
attachInteractionSession,
|
||||
enrollInteractionSession,
|
||||
fetchInteractionHealth,
|
||||
fetchInteractionSessions,
|
||||
fetchInteractionStatus,
|
||||
fetchInteractionTree,
|
||||
recoverInteractionSession,
|
||||
sendInteractionMessage,
|
||||
stopInteractionSession,
|
||||
streamInteractionSession,
|
||||
} from '../tui/gateway-api.js';
|
||||
|
||||
interface InteractionOptions {
|
||||
gateway: string;
|
||||
agent?: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
function configuredAgentName(options: InteractionOptions): string {
|
||||
const name = options.agent?.trim() || process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||
if (!name) throw new Error('Interaction agent name is required (--agent or MOSAIC_AGENT_NAME)');
|
||||
return name;
|
||||
}
|
||||
|
||||
async function authenticatedGateway(options: InteractionOptions) {
|
||||
return withAuth(options.gateway);
|
||||
}
|
||||
|
||||
async function authRequest(options: InteractionOptions) {
|
||||
const auth = await authenticatedGateway(options);
|
||||
return {
|
||||
gateway: auth.gateway,
|
||||
cookie: auth.cookie,
|
||||
agentName: configuredAgentName(options),
|
||||
correlationId: options.correlationId?.trim() || randomUUID(),
|
||||
};
|
||||
}
|
||||
|
||||
function options(command: Command): Command {
|
||||
return command
|
||||
.option('-g, --gateway <url>', 'Gateway URL', 'http://localhost:14242')
|
||||
.option('--agent <name>', 'Configured interaction agent name')
|
||||
.option('--correlation-id <id>', 'Correlation ID for audit tracing');
|
||||
}
|
||||
|
||||
function print(value: unknown): void {
|
||||
console.log(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic interaction command surface. The deployed instance name is data
|
||||
* (`--agent` / MOSAIC_AGENT_NAME), not a source-code command literal.
|
||||
*/
|
||||
export function registerInteractionCommand(program: Command): Command {
|
||||
const command = program
|
||||
.command('interaction')
|
||||
.description('Operate a configured durable interaction agent')
|
||||
.configureHelp({ sortSubcommands: true });
|
||||
|
||||
options(
|
||||
command
|
||||
.command('status')
|
||||
.description('Show credential-safe effective policy and provider status'),
|
||||
).action(async (opts: InteractionOptions) => {
|
||||
const auth = await authenticatedGateway(opts);
|
||||
print(await fetchInteractionStatus(auth.gateway, auth.cookie));
|
||||
});
|
||||
|
||||
options(
|
||||
command
|
||||
.command('health')
|
||||
.description('Show readiness without configuration or credential data'),
|
||||
).action(async (opts: InteractionOptions) => {
|
||||
print(await fetchInteractionHealth(opts.gateway));
|
||||
});
|
||||
|
||||
options(
|
||||
command
|
||||
.command('sessions <provider>')
|
||||
.description('List provider sessions visible to this actor'),
|
||||
).action(async (provider: string, opts: InteractionOptions) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await fetchInteractionSessions(request.gateway, request.cookie, {
|
||||
...request,
|
||||
providerId: provider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
options(
|
||||
command.command('tree <provider>').description('Show the provider session hierarchy'),
|
||||
).action(async (provider: string, opts: InteractionOptions) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await fetchInteractionTree(request.gateway, request.cookie, {
|
||||
...request,
|
||||
providerId: provider,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
options(
|
||||
command
|
||||
.command('enroll <sessionId> <provider> <runtimeSessionId>')
|
||||
.description('Bind an authorized runtime session to a durable conversation handle'),
|
||||
).action(
|
||||
async (
|
||||
sessionId: string,
|
||||
providerId: string,
|
||||
runtimeSessionId: string,
|
||||
opts: InteractionOptions,
|
||||
) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await enrollInteractionSession(request.gateway, request.cookie, {
|
||||
...request,
|
||||
sessionId,
|
||||
providerId,
|
||||
runtimeSessionId,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
options(
|
||||
command
|
||||
.command('attach <sessionId>')
|
||||
.description('Create a scoped read or write attachment and stream the session'),
|
||||
)
|
||||
.option('--control', 'Request control mode (provider policy may deny it)')
|
||||
.action(async (sessionId: string, opts: InteractionOptions & { control?: boolean }) => {
|
||||
const request = await authRequest(opts);
|
||||
const attachment = await attachInteractionSession(request.gateway, request.cookie, {
|
||||
...request,
|
||||
sessionId,
|
||||
mode: opts.control ? 'control' : 'read',
|
||||
});
|
||||
print(attachment);
|
||||
for await (const event of streamInteractionSession(request.gateway, request.cookie, {
|
||||
...request,
|
||||
sessionId,
|
||||
})) {
|
||||
print(event);
|
||||
}
|
||||
});
|
||||
|
||||
const send = options(
|
||||
command
|
||||
.command('send <sessionId> <message>')
|
||||
.description('Durably queue and dispatch a message'),
|
||||
)
|
||||
.option('--idempotency-key <key>', 'Stable idempotency key')
|
||||
.action(
|
||||
async (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
opts: InteractionOptions & { idempotencyKey?: string },
|
||||
) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await sendInteractionMessage(request.gateway, request.cookie, {
|
||||
...request,
|
||||
sessionId,
|
||||
content: message,
|
||||
idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
void send;
|
||||
|
||||
options(
|
||||
command
|
||||
.command('chat <sessionId> <message>')
|
||||
.description('Send a chat message through the durable interaction session'),
|
||||
)
|
||||
.option('--idempotency-key <key>', 'Stable idempotency key')
|
||||
.action(
|
||||
async (
|
||||
sessionId: string,
|
||||
message: string,
|
||||
opts: InteractionOptions & { idempotencyKey?: string },
|
||||
) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await sendInteractionMessage(request.gateway, request.cookie, {
|
||||
...request,
|
||||
sessionId,
|
||||
content: message,
|
||||
idempotencyKey: opts.idempotencyKey?.trim() || randomUUID(),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
options(
|
||||
command
|
||||
.command('stop <sessionId>')
|
||||
.description('Terminate a session using a one-time exact-action approval'),
|
||||
)
|
||||
.requiredOption('--approval <ref>', 'Durable exact-action approval reference')
|
||||
.action(async (sessionId: string, opts: InteractionOptions & { approval: string }) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await stopInteractionSession(request.gateway, request.cookie, {
|
||||
...request,
|
||||
sessionId,
|
||||
approvalRef: opts.approval,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
options(
|
||||
command
|
||||
.command('recover <sessionId>')
|
||||
.description(
|
||||
'Recover durable inbox/checkpoint state without replaying ambiguous outbox effects',
|
||||
),
|
||||
).action(async (sessionId: string, opts: InteractionOptions) => {
|
||||
const request = await authRequest(opts);
|
||||
print(
|
||||
await recoverInteractionSession(request.gateway, request.cookie, { ...request, sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
@@ -395,6 +395,9 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
|
||||
if (persona) parts.push('\n\n' + persona);
|
||||
|
||||
const toolPolicy = readFleetToolPolicyBlock(process.env['MOSAIC_AGENT_TOOL_POLICY']);
|
||||
if (toolPolicy) parts.push('\n\n' + toolPolicy);
|
||||
|
||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||
// knows how to reach the orchestrator and its peers from its first turn.
|
||||
@@ -404,6 +407,17 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
function readFleetToolPolicyBlock(policy: string | undefined): string {
|
||||
if (policy !== 'operator-interaction') return '';
|
||||
return [
|
||||
'# Fleet Tool Policy (operator-interaction)',
|
||||
'',
|
||||
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
||||
'Delegate Mos-owned work through the authorized handoff boundary.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
return composeContract(runtime);
|
||||
|
||||
110
packages/mosaic/src/fleet/interaction-service-profile.ts
Normal file
110
packages/mosaic/src/fleet/interaction-service-profile.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import YAML from 'yaml';
|
||||
import type { FleetAgent } from '../commands/fleet.js';
|
||||
|
||||
const REQUIRED_RUNTIME = 'pi';
|
||||
const REQUIRED_MODEL = 'openai/gpt-5.6-sol';
|
||||
const REQUIRED_REASONING = 'high';
|
||||
const REQUIRED_TOOL_POLICY = 'operator-interaction';
|
||||
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
export interface InteractionServiceProfile {
|
||||
runtime: string;
|
||||
model: string;
|
||||
reasoning: string;
|
||||
toolPolicy: string;
|
||||
}
|
||||
|
||||
export interface EffectiveInteractionPolicy {
|
||||
agentName: string;
|
||||
runtime: string;
|
||||
model: string;
|
||||
reasoning: string;
|
||||
toolPolicy: string;
|
||||
}
|
||||
|
||||
export class InteractionServiceProfileError extends Error {
|
||||
constructor(
|
||||
readonly code: 'invalid_profile' | 'invalid_request',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = InteractionServiceProfileError.name;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readInteractionServiceProfile(
|
||||
path: string,
|
||||
overrides: Partial<InteractionServiceProfile> = {},
|
||||
): Promise<InteractionServiceProfile> {
|
||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
||||
if (!isRecord(parsed)) {
|
||||
throw new InteractionServiceProfileError(
|
||||
'invalid_profile',
|
||||
'Service profile must be an object',
|
||||
);
|
||||
}
|
||||
const profile: InteractionServiceProfile = {
|
||||
runtime: valueOrUndefined(overrides.runtime, parsed['runtime']),
|
||||
model: valueOrUndefined(overrides.model, parsed['model']),
|
||||
reasoning: valueOrUndefined(overrides.reasoning, parsed['reasoning']),
|
||||
toolPolicy: valueOrUndefined(overrides.toolPolicy, parsed['tool_policy']),
|
||||
};
|
||||
assertPinnedPolicy(profile);
|
||||
return profile;
|
||||
}
|
||||
|
||||
export function provisionInteractionService(
|
||||
profile: InteractionServiceProfile,
|
||||
input: { agentName: string },
|
||||
): { rosterAgent: FleetAgent; effectivePolicy: EffectiveInteractionPolicy } {
|
||||
assertPinnedPolicy(profile);
|
||||
if (!AGENT_NAME_PATTERN.test(input.agentName)) {
|
||||
throw new InteractionServiceProfileError(
|
||||
'invalid_request',
|
||||
'Agent name must contain only letters, numbers, dots, underscores, or hyphens',
|
||||
);
|
||||
}
|
||||
const effectivePolicy: EffectiveInteractionPolicy = {
|
||||
agentName: input.agentName,
|
||||
runtime: profile.runtime,
|
||||
model: profile.model,
|
||||
reasoning: profile.reasoning,
|
||||
toolPolicy: profile.toolPolicy,
|
||||
};
|
||||
return {
|
||||
rosterAgent: {
|
||||
name: input.agentName,
|
||||
runtime: profile.runtime,
|
||||
className: profile.toolPolicy,
|
||||
modelHint: profile.model,
|
||||
reasoningLevel: profile.reasoning,
|
||||
toolPolicy: profile.toolPolicy,
|
||||
persistentPersona: true,
|
||||
},
|
||||
effectivePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
function assertPinnedPolicy(profile: InteractionServiceProfile): void {
|
||||
const invalid =
|
||||
profile.runtime !== REQUIRED_RUNTIME ||
|
||||
profile.model !== REQUIRED_MODEL ||
|
||||
profile.reasoning !== REQUIRED_REASONING ||
|
||||
profile.toolPolicy !== REQUIRED_TOOL_POLICY;
|
||||
if (invalid) {
|
||||
throw new InteractionServiceProfileError(
|
||||
'invalid_profile',
|
||||
`Service profile must pin ${REQUIRED_RUNTIME}, ${REQUIRED_MODEL}, ${REQUIRED_REASONING}, and ${REQUIRED_TOOL_POLICY}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function valueOrUndefined(override: string | undefined, value: unknown): string {
|
||||
if (override !== undefined) return override;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
161
packages/mosaic/src/fleet/tess-service-profile.test.ts
Normal file
161
packages/mosaic/src/fleet/tess-service-profile.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { promisify } from 'node:util';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { generateAgentEnv, loadFleetRoster } from '../commands/fleet.js';
|
||||
import {
|
||||
provisionInteractionService,
|
||||
readInteractionServiceProfile,
|
||||
} from './interaction-service-profile.js';
|
||||
import type { InteractionServiceProfileError } from './interaction-service-profile.js';
|
||||
|
||||
const frameworkFleet = resolve(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'..',
|
||||
'framework',
|
||||
'fleet',
|
||||
);
|
||||
const profilePath = join(frameworkFleet, 'services', 'operator-interaction.yaml');
|
||||
const examplePath = join(frameworkFleet, 'examples', 'operator-interaction.yaml');
|
||||
const policyScript = join(
|
||||
frameworkFleet,
|
||||
'..',
|
||||
'tools',
|
||||
'fleet',
|
||||
'print-interaction-effective-policy.sh',
|
||||
);
|
||||
const interactionStartScript = join(
|
||||
frameworkFleet,
|
||||
'..',
|
||||
'tools',
|
||||
'fleet',
|
||||
'start-interaction-service.sh',
|
||||
);
|
||||
const agentStartScript = join(frameworkFleet, '..', 'tools', 'fleet', 'start-agent-session.sh');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe('operator interaction service profile', (): void => {
|
||||
it('provisions a user-supplied Nova identity as data with the required effective policy', async (): Promise<void> => {
|
||||
const profile = await readInteractionServiceProfile(profilePath);
|
||||
const provisioned = provisionInteractionService(profile, { agentName: 'Nova' });
|
||||
|
||||
expect(provisioned.rosterAgent).toEqual({
|
||||
name: 'Nova',
|
||||
runtime: 'pi',
|
||||
className: 'operator-interaction',
|
||||
modelHint: 'openai/gpt-5.6-sol',
|
||||
reasoningLevel: 'high',
|
||||
toolPolicy: 'operator-interaction',
|
||||
persistentPersona: true,
|
||||
});
|
||||
expect(provisioned.effectivePolicy).toEqual({
|
||||
agentName: 'Nova',
|
||||
runtime: 'pi',
|
||||
model: 'openai/gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'operator-interaction',
|
||||
});
|
||||
expect(JSON.stringify(provisioned.effectivePolicy)).not.toMatch(
|
||||
/token|secret|credential|api.?key/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a renamed example roster and surfaces only its effective policy', async (): Promise<void> => {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'mosaic-interaction-profile-'));
|
||||
const rosterPath = join(directory, 'roster.yaml');
|
||||
try {
|
||||
const example = await readFile(examplePath, 'utf8');
|
||||
await writeFile(rosterPath, example.replace('name: Tess', 'name: Nova'), 'utf8');
|
||||
const roster = await loadFleetRoster(rosterPath);
|
||||
const agent = roster.agents[0]!;
|
||||
|
||||
expect(agent.name).toBe('Nova');
|
||||
expect(agent.reasoningLevel).toBe('high');
|
||||
expect(agent.toolPolicy).toBe('operator-interaction');
|
||||
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_NAME=Nova');
|
||||
expect(generateAgentEnv(roster, agent)).toContain('MOSAIC_AGENT_REASONING=high');
|
||||
|
||||
const { stdout } = await execFileAsync(policyScript, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_AGENT_NAME: agent.name,
|
||||
MOSAIC_AGENT_RUNTIME: agent.runtime,
|
||||
MOSAIC_AGENT_MODEL: agent.modelHint,
|
||||
MOSAIC_AGENT_REASONING: agent.reasoningLevel,
|
||||
MOSAIC_AGENT_TOOL_POLICY: agent.toolPolicy,
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(stdout)).toEqual({
|
||||
agentName: 'Nova',
|
||||
runtime: 'pi',
|
||||
model: 'openai/gpt-5.6-sol',
|
||||
reasoning: 'high',
|
||||
toolPolicy: 'operator-interaction',
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fails before launch when service environment drifts from the pinned policy', async (): Promise<void> => {
|
||||
await expect(
|
||||
execFileAsync(interactionStartScript, ['Nova'], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_AGENT_NAME: 'Nova',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'other/model',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 64 });
|
||||
});
|
||||
|
||||
it('rejects unsafe names from the policy printer and reasoning before tmux launch', async (): Promise<void> => {
|
||||
await expect(
|
||||
execFileAsync(policyScript, [], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSAIC_AGENT_NAME: 'Nova"bad',
|
||||
MOSAIC_AGENT_RUNTIME: 'pi',
|
||||
MOSAIC_AGENT_MODEL: 'openai/gpt-5.6-sol',
|
||||
MOSAIC_AGENT_REASONING: 'high',
|
||||
MOSAIC_AGENT_TOOL_POLICY: 'operator-interaction',
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 64 });
|
||||
await expect(
|
||||
execFileAsync(agentStartScript, ['Nova'], {
|
||||
env: { ...process.env, MOSAIC_AGENT_REASONING: 'high; id' },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 64 });
|
||||
});
|
||||
|
||||
it('keeps the product name confined to an example instance, not service source or defaults', async (): Promise<void> => {
|
||||
const [profile, source, example] = await Promise.all([
|
||||
readFile(profilePath, 'utf8'),
|
||||
readFile(new URL('./interaction-service-profile.ts', import.meta.url), 'utf8'),
|
||||
readFile(examplePath, 'utf8'),
|
||||
]);
|
||||
|
||||
expect(profile).not.toMatch(/tess/i);
|
||||
expect(source).not.toMatch(/tess/i);
|
||||
expect(example).toMatch(/name: Tess/);
|
||||
});
|
||||
|
||||
it('fails fast when a required policy field is absent or changed from the pinned policy', async (): Promise<void> => {
|
||||
await expect(readInteractionServiceProfile(profilePath, { model: '' })).rejects.toMatchObject({
|
||||
code: 'invalid_profile',
|
||||
} satisfies Partial<InteractionServiceProfileError>);
|
||||
await expect(
|
||||
readInteractionServiceProfile(profilePath, { reasoning: 'medium' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_profile',
|
||||
} satisfies Partial<InteractionServiceProfileError>);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
export const VERSION = '0.0.0';
|
||||
|
||||
export * from './fleet/interaction-service-profile.js';
|
||||
export * from './fleet/tmux-runtime-transport.js';
|
||||
|
||||
export {
|
||||
|
||||
@@ -361,6 +361,207 @@ export async function deleteMission(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Authenticated interaction runtime endpoints ──
|
||||
|
||||
export interface InteractionRequest {
|
||||
agentName: string;
|
||||
correlationId?: string;
|
||||
}
|
||||
|
||||
function interactionHeaders(sessionCookie: string, gatewayUrl: string, correlationId?: string) {
|
||||
return {
|
||||
...jsonHeaders(sessionCookie, gatewayUrl),
|
||||
...(correlationId ? { 'X-Correlation-Id': correlationId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function interactionPath(agentName: string, suffix: string): string {
|
||||
return `/api/interaction/${encodeURIComponent(agentName)}${suffix}`;
|
||||
}
|
||||
|
||||
export async function fetchInteractionSessions(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { providerId: string },
|
||||
): Promise<unknown[]> {
|
||||
const params = new URLSearchParams({ provider: request.providerId });
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions?${params}`)}`,
|
||||
{
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
},
|
||||
);
|
||||
return handleResponse<unknown[]>(res, 'Failed to list interaction sessions');
|
||||
}
|
||||
|
||||
export async function fetchInteractionTree(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { providerId: string },
|
||||
): Promise<unknown[]> {
|
||||
const params = new URLSearchParams({ provider: request.providerId });
|
||||
const res = await fetch(`${gatewayUrl}${interactionPath(request.agentName, `/tree?${params}`)}`, {
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
});
|
||||
return handleResponse<unknown[]>(res, 'Failed to get interaction session tree');
|
||||
}
|
||||
|
||||
export async function enrollInteractionSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & {
|
||||
sessionId: string;
|
||||
providerId: string;
|
||||
runtimeSessionId: string;
|
||||
},
|
||||
): Promise<{ status: string; sessionId: string }> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/enroll`)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
body: JSON.stringify({
|
||||
providerId: request.providerId,
|
||||
runtimeSessionId: request.runtimeSessionId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
return handleResponse<{ status: string; sessionId: string }>(
|
||||
res,
|
||||
'Failed to enroll interaction session',
|
||||
);
|
||||
}
|
||||
|
||||
export async function attachInteractionSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { sessionId: string; mode?: 'read' | 'control' },
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/attach`)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
body: JSON.stringify({ mode: request.mode ?? 'read' }),
|
||||
},
|
||||
);
|
||||
return handleResponse<unknown>(res, 'Failed to attach interaction session');
|
||||
}
|
||||
|
||||
export async function* streamInteractionSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { sessionId: string; cursor?: string },
|
||||
): AsyncIterable<unknown> {
|
||||
const params = request.cursor?.trim()
|
||||
? `?${new URLSearchParams({ cursor: request.cursor.trim() }).toString()}`
|
||||
: '';
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stream${params}`)}`,
|
||||
{ headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId) },
|
||||
);
|
||||
if (!res.ok || !res.body) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Failed to stream interaction session (${res.status}): ${body}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let pending = '';
|
||||
try {
|
||||
for (;;) {
|
||||
const chunk = await reader.read();
|
||||
if (chunk.done) break;
|
||||
pending += decoder.decode(chunk.value, { stream: true });
|
||||
for (;;) {
|
||||
const separator = pending.indexOf('\n\n');
|
||||
if (separator < 0) break;
|
||||
const frame = pending.slice(0, separator);
|
||||
pending = pending.slice(separator + 2);
|
||||
const data = frame
|
||||
.split('\n')
|
||||
.find((line: string): boolean => line.startsWith('data:'))
|
||||
?.slice('data:'.length)
|
||||
.trim();
|
||||
if (data) yield JSON.parse(data) as unknown;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendInteractionMessage(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { sessionId: string; content: string; idempotencyKey: string },
|
||||
): Promise<{ status: string; sessionId: string }> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/send`)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
body: JSON.stringify({ content: request.content, idempotencyKey: request.idempotencyKey }),
|
||||
},
|
||||
);
|
||||
return handleResponse<{ status: string; sessionId: string }>(
|
||||
res,
|
||||
'Failed to send interaction message',
|
||||
);
|
||||
}
|
||||
|
||||
export async function stopInteractionSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { sessionId: string; approvalRef: string },
|
||||
): Promise<{ status: string; sessionId: string }> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/stop`)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
body: JSON.stringify({ approvalRef: request.approvalRef }),
|
||||
},
|
||||
);
|
||||
return handleResponse<{ status: string; sessionId: string }>(
|
||||
res,
|
||||
'Failed to stop interaction session',
|
||||
);
|
||||
}
|
||||
|
||||
export async function recoverInteractionSession(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
request: InteractionRequest & { sessionId: string },
|
||||
): Promise<{ status: string; sessionId: string }> {
|
||||
const res = await fetch(
|
||||
`${gatewayUrl}${interactionPath(request.agentName, `/sessions/${encodeURIComponent(request.sessionId)}/recover`)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: interactionHeaders(sessionCookie, gatewayUrl, request.correlationId),
|
||||
},
|
||||
);
|
||||
return handleResponse<{ status: string; sessionId: string }>(
|
||||
res,
|
||||
'Failed to recover interaction session',
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchInteractionStatus(
|
||||
gatewayUrl: string,
|
||||
sessionCookie: string,
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(`${gatewayUrl}/api/providers/status`, {
|
||||
headers: headers(sessionCookie, gatewayUrl),
|
||||
});
|
||||
return handleResponse<unknown>(res, 'Failed to get effective interaction policy');
|
||||
}
|
||||
|
||||
export async function fetchInteractionHealth(gatewayUrl: string): Promise<unknown> {
|
||||
const res = await fetch(`${gatewayUrl}/health/ready`);
|
||||
return handleResponse<unknown>(res, 'Failed to get interaction readiness');
|
||||
}
|
||||
|
||||
// ── Conversation Message types ──
|
||||
|
||||
export interface ConversationMessage {
|
||||
|
||||
@@ -6,6 +6,24 @@ export type RuntimeCapability =
|
||||
| 'session.attach'
|
||||
| 'session.terminate';
|
||||
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
||||
|
||||
/** Transitional capability inventory is normalized; provider legacy vocabularies never enter core. */
|
||||
export type TransitionalRuntimeCapability = 'kanban' | 'skills' | 'memory' | 'tools' | 'cron';
|
||||
export type TransitionalCapabilityStatus = 'supported' | 'unsupported';
|
||||
export interface TransitionalCapabilityInventoryEntry {
|
||||
capability: TransitionalRuntimeCapability;
|
||||
status: TransitionalCapabilityStatus;
|
||||
}
|
||||
/** Optional extension for transitional adapters; not every runtime has Hermes inventory. */
|
||||
export interface TransitionalCapabilityInventoryProvider {
|
||||
transitionalCapabilityMatrix(
|
||||
scope: RuntimeScope,
|
||||
): Promise<TransitionalCapabilityInventoryEntry[]>;
|
||||
assertTransitionalCapability(
|
||||
capability: TransitionalRuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void>;
|
||||
}
|
||||
export type RuntimeAttachMode = 'read' | 'control';
|
||||
|
||||
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
||||
|
||||
@@ -12,6 +12,129 @@ export interface DiscordPluginConfig {
|
||||
allowedGuildIds: readonly string[];
|
||||
allowedChannelIds: readonly string[];
|
||||
allowedUserIds: readonly string[];
|
||||
/** Provisioned interaction bindings; instance identity is configuration, never code. */
|
||||
interactionBindings?: readonly DiscordInteractionBinding[];
|
||||
}
|
||||
|
||||
export type DiscordInteractionOperation = 'bind' | 'attach' | 'send' | 'approve' | 'stop';
|
||||
export type DiscordInteractionRole = 'viewer' | 'operator' | 'admin';
|
||||
|
||||
/** A provisioned Discord-to-Mosaic identity pairing. */
|
||||
export interface DiscordInteractionUserBinding {
|
||||
role: DiscordInteractionRole;
|
||||
/** Required for privileged approval and stop operations. */
|
||||
mosaicUserId?: string;
|
||||
}
|
||||
|
||||
/** Legacy role-only pairings remain valid for non-privileged Discord ingress. */
|
||||
export type DiscordInteractionPairing = DiscordInteractionRole | DiscordInteractionUserBinding;
|
||||
|
||||
export interface DiscordInteractionBinding {
|
||||
instanceId: string;
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
/** Pairing roster keyed by Discord user ID. */
|
||||
pairedUsers: Readonly<Record<string, DiscordInteractionPairing>>;
|
||||
}
|
||||
|
||||
const operationRoles: Readonly<
|
||||
Record<DiscordInteractionOperation, readonly DiscordInteractionRole[]>
|
||||
> = {
|
||||
bind: ['admin'],
|
||||
attach: ['operator', 'admin'],
|
||||
send: ['operator', 'admin'],
|
||||
approve: ['admin'],
|
||||
stop: ['admin'],
|
||||
};
|
||||
|
||||
/** Resolves a configuration-owned binding and applies pairing/RBAC before ingress. */
|
||||
export function resolveDiscordInteractionBinding(
|
||||
bindings: readonly DiscordInteractionBinding[],
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
userId: string,
|
||||
operation: DiscordInteractionOperation,
|
||||
): DiscordInteractionBinding | null {
|
||||
const binding = bindings.find(
|
||||
(candidate) => candidate.guildId === guildId && candidate.channelId === channelId,
|
||||
);
|
||||
if (!binding) return null;
|
||||
const pairing = binding.pairedUsers[userId];
|
||||
const role = typeof pairing === 'string' ? pairing : pairing?.role;
|
||||
return role && operationRoles[operation].includes(role) ? binding : null;
|
||||
}
|
||||
|
||||
/** Resolves the provisioned Mosaic identity for an already-authorized Discord user. */
|
||||
export function resolveDiscordInteractionActorId(
|
||||
binding: DiscordInteractionBinding,
|
||||
discordUserId: string,
|
||||
): string | null {
|
||||
const pairing = binding.pairedUsers[discordUserId];
|
||||
if (typeof pairing === 'string') return null;
|
||||
const mosaicUserId = pairing?.mosaicUserId?.trim();
|
||||
return mosaicUserId || null;
|
||||
}
|
||||
|
||||
/** Parses provisioned binding roster JSON and rejects malformed or empty data. */
|
||||
export function parseDiscordInteractionBindings(
|
||||
value: string | undefined,
|
||||
): DiscordInteractionBinding[] {
|
||||
if (!value) throw new Error('DISCORD_INTERACTION_BINDINGS is required when Discord is enabled');
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
throw new Error('DISCORD_INTERACTION_BINDINGS must be a non-empty JSON array');
|
||||
}
|
||||
return parsed.map((binding: unknown): DiscordInteractionBinding => {
|
||||
if (typeof binding !== 'object' || binding === null)
|
||||
throw new Error('Invalid Discord interaction binding');
|
||||
const candidate = binding as Partial<DiscordInteractionBinding>;
|
||||
if (
|
||||
!candidate.instanceId ||
|
||||
!candidate.guildId ||
|
||||
!candidate.channelId ||
|
||||
!candidate.pairedUsers ||
|
||||
typeof candidate.pairedUsers !== 'object'
|
||||
) {
|
||||
throw new Error('Invalid Discord interaction binding');
|
||||
}
|
||||
const pairedUsers = Object.fromEntries(
|
||||
Object.entries(candidate.pairedUsers).map(([discordUserId, pairing]: [string, unknown]) => {
|
||||
if (!discordUserId.trim()) {
|
||||
throw new Error('Invalid Discord interaction user binding');
|
||||
}
|
||||
if (typeof pairing === 'string') {
|
||||
if (!['viewer', 'operator', 'admin'].includes(pairing)) {
|
||||
throw new Error('Invalid Discord interaction user binding');
|
||||
}
|
||||
return [discordUserId, pairing];
|
||||
}
|
||||
if (typeof pairing !== 'object' || pairing === null) {
|
||||
throw new Error('Invalid Discord interaction user binding');
|
||||
}
|
||||
const userBinding = pairing as Partial<DiscordInteractionUserBinding>;
|
||||
if (
|
||||
!userBinding.role ||
|
||||
!['viewer', 'operator', 'admin'].includes(userBinding.role) ||
|
||||
(userBinding.mosaicUserId !== undefined && !userBinding.mosaicUserId.trim())
|
||||
) {
|
||||
throw new Error('Invalid Discord interaction user binding');
|
||||
}
|
||||
return [
|
||||
discordUserId,
|
||||
{
|
||||
role: userBinding.role,
|
||||
...(userBinding.mosaicUserId ? { mosaicUserId: userBinding.mosaicUserId } : {}),
|
||||
},
|
||||
];
|
||||
}),
|
||||
) as Record<string, DiscordInteractionPairing>;
|
||||
return {
|
||||
instanceId: candidate.instanceId,
|
||||
guildId: candidate.guildId,
|
||||
channelId: candidate.channelId,
|
||||
pairedUsers,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export interface DiscordIngressPayload {
|
||||
@@ -22,6 +145,15 @@ export interface DiscordIngressPayload {
|
||||
userId: string;
|
||||
conversationId: string;
|
||||
content: string;
|
||||
threadId?: string;
|
||||
attachments?: readonly DiscordAttachment[];
|
||||
}
|
||||
|
||||
export interface DiscordAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
contentType: string | null;
|
||||
}
|
||||
|
||||
export interface DiscordIngressEnvelope {
|
||||
@@ -44,6 +176,8 @@ function signedPayload(payload: DiscordIngressPayload): string {
|
||||
payload.userId,
|
||||
payload.conversationId,
|
||||
payload.content,
|
||||
payload.threadId ?? '',
|
||||
JSON.stringify(payload.attachments ?? []),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -214,7 +348,18 @@ export class DiscordPlugin {
|
||||
}
|
||||
|
||||
const channelId = message.channelId;
|
||||
const conversationId = this.channelConversations.get(channelId) ?? `discord-${channelId}`;
|
||||
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||
const bindingChannelId = parentChannelId ?? channelId;
|
||||
const binding = resolveDiscordInteractionBinding(
|
||||
this.config.interactionBindings ?? [],
|
||||
message.guildId,
|
||||
bindingChannelId,
|
||||
message.author.id,
|
||||
'send',
|
||||
);
|
||||
if (!binding) return;
|
||||
const conversationId =
|
||||
this.channelConversations.get(channelId) ?? `${binding.instanceId}:discord:${channelId}`;
|
||||
this.channelConversations.set(channelId, conversationId);
|
||||
|
||||
const envelope = createDiscordIngressEnvelope(
|
||||
@@ -222,22 +367,39 @@ export class DiscordPlugin {
|
||||
correlationId: randomUUID(),
|
||||
messageId: message.id,
|
||||
guildId: message.guildId,
|
||||
channelId,
|
||||
channelId: bindingChannelId,
|
||||
userId: message.author.id,
|
||||
conversationId,
|
||||
content,
|
||||
threadId: parentChannelId ? channelId : undefined,
|
||||
attachments: Array.from(message.attachments.values()).map((attachment) => ({
|
||||
id: attachment.id,
|
||||
name: attachment.name,
|
||||
url: attachment.url,
|
||||
contentType: attachment.contentType,
|
||||
})),
|
||||
},
|
||||
this.config.serviceToken,
|
||||
);
|
||||
this.socket.emit('message', envelope);
|
||||
this.socket.emit(
|
||||
/^\/approve$/i.test(content)
|
||||
? 'discord:approve'
|
||||
: content.startsWith('/stop ')
|
||||
? 'discord:stop'
|
||||
: 'message',
|
||||
envelope,
|
||||
);
|
||||
}
|
||||
|
||||
private isAllowedMessage(message: DiscordMessage): boolean {
|
||||
const guildId = message.guildId;
|
||||
const parentChannelId = 'parentId' in message.channel ? message.channel.parentId : null;
|
||||
// Threads inherit their authorization boundary from their configured parent.
|
||||
const authorizationChannelId = parentChannelId ?? message.channelId;
|
||||
return (
|
||||
guildId !== null &&
|
||||
includesId(this.config.allowedGuildIds, guildId) &&
|
||||
includesId(this.config.allowedChannelIds, message.channelId) &&
|
||||
includesId(this.config.allowedChannelIds, authorizationChannelId) &&
|
||||
includesId(this.config.allowedUserIds, message.author.id)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user