Compare commits
1 Commits
main
...
ed1d985c90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed1d985c90 |
@@ -7,7 +7,7 @@ import {
|
|||||||
} from '@mosaicstack/discord-plugin';
|
} from '@mosaicstack/discord-plugin';
|
||||||
import { InteractionController } from '../../agent/interaction.controller.js';
|
import { InteractionController } from '../../agent/interaction.controller.js';
|
||||||
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
|
||||||
import { DurableSessionService } from '../../agent/durable-session.service.js';
|
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
|
||||||
import { ChatGateway } from '../../chat/chat.gateway.js';
|
import { ChatGateway } from '../../chat/chat.gateway.js';
|
||||||
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
import { CommandAuthorizationService } from '../../commands/command-authorization.service.js';
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ function authorization(): CommandAuthorizationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('interaction Discord/CLI durable-session integration', () => {
|
describe('Tess Discord/CLI durable-session integration', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const key of envKeys) {
|
for (const key of envKeys) {
|
||||||
const value = priorEnv.get(key);
|
const value = priorEnv.get(key);
|
||||||
@@ -80,7 +80,7 @@ describe('interaction Discord/CLI durable-session integration', () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const durable = new DurableSessionService(
|
const durable = new TessDurableSessionService(
|
||||||
new InMemoryDurableSessionStore() as never,
|
new InMemoryDurableSessionStore() as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,24 +12,18 @@ type AgentServiceInternals = {
|
|||||||
creating: Map<string, Promise<AgentSession>>;
|
creating: Map<string, Promise<AgentSession>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function makeService(operatorMemory: unknown = null): AgentService {
|
function makeService(): AgentService {
|
||||||
return new 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,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ available: false } as never,
|
{ available: false } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ getToolDefinitions: vi.fn(() => []) } as never,
|
{} as never,
|
||||||
{ loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
|
{} as never,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
{ collect: vi.fn().mockResolvedValue(undefined) } as never,
|
||||||
operatorMemory as never,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,37 +120,6 @@ describe('AgentService owner/tenant scope enforcement', () => {
|
|||||||
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false);
|
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 () => {
|
it('checks owner/tenant scope before returning an in-flight session creation', async () => {
|
||||||
const service = makeService();
|
const service = makeService();
|
||||||
const session = makeSession();
|
const session = makeSession();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
||||||
@@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js';
|
|||||||
import { AgentConfigsController } from './agent-configs.controller.js';
|
import { AgentConfigsController } from './agent-configs.controller.js';
|
||||||
import { InteractionController } from './interaction.controller.js';
|
import { InteractionController } from './interaction.controller.js';
|
||||||
import { RoutingController } from './routing/routing.controller.js';
|
import { RoutingController } from './routing/routing.controller.js';
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
import { CoordModule } from '../coord/coord.module.js';
|
import { CoordModule } from '../coord/coord.module.js';
|
||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
@@ -20,7 +20,6 @@ import { GCModule } from '../gc/gc.module.js';
|
|||||||
import { LogModule } from '../log/log.module.js';
|
import { LogModule } from '../log/log.module.js';
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
|
||||||
import {
|
import {
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
@@ -29,12 +28,6 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
|
|
||||||
const registry = new AgentRuntimeProviderRegistry();
|
|
||||||
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
|
|
||||||
return registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
||||||
@@ -44,11 +37,11 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
DurableSessionRepository,
|
TessDurableSessionRepository,
|
||||||
DurableSessionService,
|
TessDurableSessionService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: createGatewayRuntimeProviderRegistry,
|
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
||||||
},
|
},
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
{
|
{
|
||||||
@@ -76,7 +69,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
DurableSessionService,
|
TessDurableSessionService,
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -15,10 +15,9 @@ import {
|
|||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
} from '@mariozechner/pi-coding-agent';
|
} from '@mariozechner/pi-coding-agent';
|
||||||
import type { Brain } from '@mosaicstack/brain';
|
import type { Brain } from '@mosaicstack/brain';
|
||||||
import type { Memory, OperatorMemoryPlugin } from '@mosaicstack/memory';
|
import type { Memory } from '@mosaicstack/memory';
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
import { BRAIN } from '../brain/brain.tokens.js';
|
||||||
import { MEMORY } from '../memory/memory.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 { EmbeddingService } from '../memory/embedding.service.js';
|
||||||
import { CoordService } from '../coord/coord.service.js';
|
import { CoordService } from '../coord/coord.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
@@ -136,9 +135,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
@Inject(PreferencesService)
|
@Inject(PreferencesService)
|
||||||
private readonly preferencesService: PreferencesService | null,
|
private readonly preferencesService: PreferencesService | null,
|
||||||
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
@Inject(SessionGCService) private readonly gc: SessionGCService,
|
||||||
@Optional()
|
|
||||||
@Inject(OPERATOR_MEMORY_PLUGIN)
|
|
||||||
private readonly operatorMemory: OperatorMemoryPlugin | null = null,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -150,7 +146,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
private buildToolsForSandbox(
|
private buildToolsForSandbox(
|
||||||
sandboxDir: string,
|
sandboxDir: string,
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
sessionScope?: { tenantId: string; ownerId: string; sessionId: string },
|
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
return [
|
return [
|
||||||
...createBrainTools(this.brain),
|
...createBrainTools(this.brain),
|
||||||
@@ -159,9 +154,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
this.memory,
|
this.memory,
|
||||||
this.embeddingService.available ? this.embeddingService : null,
|
this.embeddingService.available ? this.embeddingService : null,
|
||||||
sessionUserId,
|
sessionUserId,
|
||||||
this.operatorMemory && sessionScope
|
|
||||||
? { plugin: this.operatorMemory, scope: sessionScope }
|
|
||||||
: undefined,
|
|
||||||
),
|
),
|
||||||
...createFileTools(sandboxDir),
|
...createFileTools(sandboxDir),
|
||||||
...createGitTools(sandboxDir),
|
...createGitTools(sandboxDir),
|
||||||
@@ -236,7 +228,6 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
isAdmin: options.isAdmin,
|
isAdmin: options.isAdmin,
|
||||||
agentConfigId: options.agentConfigId,
|
agentConfigId: options.agentConfigId,
|
||||||
userId: options.userId,
|
userId: options.userId,
|
||||||
tenantId: options.tenantId,
|
|
||||||
conversationHistory: options.conversationHistory,
|
conversationHistory: options.conversationHistory,
|
||||||
};
|
};
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
@@ -276,15 +267,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build per-session tools scoped to the sandbox directory and authenticated user
|
// Build per-session tools scoped to the sandbox directory and authenticated user
|
||||||
const sessionUserId = mergedOptions?.userId;
|
const sandboxTools = this.buildToolsForSandbox(sandboxDir, 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
|
// Combine static tools with dynamically discovered MCP client tools and skill tools
|
||||||
const mcpTools = this.mcpClientService.getToolDefinitions();
|
const mcpTools = this.mcpClientService.getToolDefinitions();
|
||||||
@@ -379,7 +362,7 @@ export class AgentService implements OnModuleDestroy {
|
|||||||
sandboxDir,
|
sandboxDir,
|
||||||
allowedTools,
|
allowedTools,
|
||||||
userId: mergedOptions?.userId,
|
userId: mergedOptions?.userId,
|
||||||
tenantId: sessionTenantId,
|
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId),
|
||||||
agentConfigId: mergedOptions?.agentConfigId,
|
agentConfigId: mergedOptions?.agentConfigId,
|
||||||
agentName: resolvedAgentName,
|
agentName: resolvedAgentName,
|
||||||
metrics: {
|
metrics: {
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { HermesRuntimeProvider } from '@mosaicstack/agent';
|
|
||||||
import { AgentModule } from './agent.module.js';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { BRAIN } from '../brain/brain.tokens.js';
|
|
||||||
import { DB } from '../database/database.module.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 {
|
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
|
||||||
RUNTIME_PROVIDER_AUDIT_SINK,
|
|
||||||
RuntimeProviderAuditService,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
|
||||||
import { AgentService } from './agent.service.js';
|
|
||||||
import { ProviderService } from './provider.service.js';
|
|
||||||
import { ProviderCredentialsService } from './provider-credentials.service.js';
|
|
||||||
import { RoutingService } from './routing.service.js';
|
|
||||||
import { RoutingEngineService } from './routing/routing-engine.service.js';
|
|
||||||
import { SkillLoaderService } from './skill-loader.service.js';
|
|
||||||
|
|
||||||
const authenticatedUser = { id: 'operator-1', tenantId: 'tenant-1' };
|
|
||||||
|
|
||||||
@Module({})
|
|
||||||
class EmptyAgentDependencyModule {}
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUTH,
|
|
||||||
useValue: {
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
|
||||||
headers.get('cookie') === 'session=trusted'
|
|
||||||
? { user: authenticatedUser, session: { id: 'session-1' } }
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthGuard,
|
|
||||||
{ provide: BRAIN, useValue: {} },
|
|
||||||
{ provide: DB, useValue: {} },
|
|
||||||
],
|
|
||||||
exports: [AUTH, AuthGuard, BRAIN, DB],
|
|
||||||
})
|
|
||||||
class AuthenticatedRequestModule {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is deliberately an HTTP test rather than a controller unit test: it
|
|
||||||
* exercises AgentModule's actual provider factory, Nest DI, and AuthGuard.
|
|
||||||
*/
|
|
||||||
describe('Hermes runtime provider reachability', (): void => {
|
|
||||||
let app: NestFastifyApplication | undefined;
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [AuthenticatedRequestModule, AgentModule],
|
|
||||||
})
|
|
||||||
.overrideModule(CoordModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(McpClientModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(SkillsModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(GCModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(LogModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideModule(CommandsModule)
|
|
||||||
.useModule(EmptyAgentDependencyModule)
|
|
||||||
.overrideProvider(RuntimeProviderAuditService)
|
|
||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
|
||||||
.overrideProvider(RUNTIME_PROVIDER_AUDIT_SINK)
|
|
||||||
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
|
|
||||||
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
|
|
||||||
.useValue({ consume: vi.fn().mockResolvedValue(false) })
|
|
||||||
.overrideProvider(DurableSessionService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(DurableSessionRepository)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(AgentService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(ProviderService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(ProviderCredentialsService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(RoutingService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(RoutingEngineService)
|
|
||||||
.useValue({})
|
|
||||||
.overrideProvider(SkillLoaderService)
|
|
||||||
.useValue({})
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async (): Promise<void> => {
|
|
||||||
await app?.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns gateway denial responses from the actual guarded interaction routes', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
|
||||||
|
|
||||||
const attachDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/attach',
|
|
||||||
headers: { 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: { mode: 'read' },
|
|
||||||
});
|
|
||||||
expect(attachDenied.statusCode).toBe(401);
|
|
||||||
|
|
||||||
const sendDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/send',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(sendDenied.statusCode).toBe(403);
|
|
||||||
expect(sendDenied.json()).toMatchObject({
|
|
||||||
message: 'Content and idempotency key are required',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopDenied = await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/interaction/Nova/sessions/session-1/stop',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
payload: {},
|
|
||||||
});
|
|
||||||
expect(stopDenied.statusCode).toBe(403);
|
|
||||||
expect(stopDenied.json()).toMatchObject({ message: 'Exact-action approval is required' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
|
||||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
|
||||||
expect(registry.get('runtime.hermes')).toBeInstanceOf(HermesRuntimeProvider);
|
|
||||||
|
|
||||||
const denied = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
|
||||||
headers: { 'x-correlation-id': 'correlation-1' },
|
|
||||||
});
|
|
||||||
expect(denied.statusCode).toBe(401);
|
|
||||||
|
|
||||||
const response = await app.inject({
|
|
||||||
method: 'GET',
|
|
||||||
url: '/api/interaction/Nova/transitional-capabilities?provider=runtime.hermes',
|
|
||||||
headers: { cookie: 'session=trusted', 'x-correlation-id': 'correlation-1' },
|
|
||||||
});
|
|
||||||
expect(response.statusCode).toBe(200);
|
|
||||||
expect(response.json()).toEqual([
|
|
||||||
{ capability: 'kanban', status: 'unsupported' },
|
|
||||||
{ capability: 'skills', status: 'unsupported' },
|
|
||||||
{ capability: 'memory', status: 'unsupported' },
|
|
||||||
{ capability: 'tools', status: 'unsupported' },
|
|
||||||
{ capability: 'cron', status: 'unsupported' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
|
||||||
|
|
||||||
const scope = {
|
|
||||||
actorId: 'owner-1',
|
|
||||||
tenantId: 'tenant-1',
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'correlation-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('GatewayHermesRuntimeTransport', () => {
|
|
||||||
it('preserves a configured path prefix and authenticates the concrete runtime request', async () => {
|
|
||||||
const fetchFn = vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 }));
|
|
||||||
const transport = new GatewayHermesRuntimeTransport(
|
|
||||||
'https://runtime.example.test/hermes',
|
|
||||||
'test-service-token',
|
|
||||||
fetchFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']);
|
|
||||||
|
|
||||||
expect(fetchFn).toHaveBeenCalledWith(
|
|
||||||
new URL('https://runtime.example.test/hermes/capabilities'),
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({
|
|
||||||
authorization: 'Bearer test-service-token',
|
|
||||||
'x-mosaic-channel-id': 'cli',
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => {
|
|
||||||
const fetchFn = vi.fn();
|
|
||||||
const transport = new GatewayHermesRuntimeTransport(
|
|
||||||
'http://runtime.example.test/hermes',
|
|
||||||
'test-service-token',
|
|
||||||
fetchFn,
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS');
|
|
||||||
expect(fetchFn).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent';
|
|
||||||
import type {
|
|
||||||
RuntimeAttachHandle,
|
|
||||||
RuntimeAttachMode,
|
|
||||||
RuntimeMessage,
|
|
||||||
RuntimeScope,
|
|
||||||
RuntimeStreamEvent,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */
|
|
||||||
export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport {
|
|
||||||
constructor(
|
|
||||||
private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(),
|
|
||||||
private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(),
|
|
||||||
private readonly fetchFn: typeof fetch = fetch,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async capabilities(scope: RuntimeScope): Promise<string[]> {
|
|
||||||
return this.request<string[]>('/capabilities', scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> {
|
|
||||||
return this.request<{ status: string; detail?: string }>('/health', scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sessions(scope: RuntimeScope): Promise<HermesLegacySession[]> {
|
|
||||||
return this.request<HermesLegacySession[]>('/sessions', scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async *stream(
|
|
||||||
sessionId: string,
|
|
||||||
cursor: string | undefined,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
const params = new URLSearchParams(cursor ? { cursor } : {});
|
|
||||||
const events = await this.request<RuntimeStreamEvent[]>(
|
|
||||||
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
yield* events;
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
|
||||||
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
|
|
||||||
method: 'POST',
|
|
||||||
body: message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(
|
|
||||||
sessionId: string,
|
|
||||||
mode: RuntimeAttachMode,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<RuntimeAttachHandle> {
|
|
||||||
return this.request<RuntimeAttachHandle>(
|
|
||||||
`/sessions/${encodeURIComponent(sessionId)}/attach`,
|
|
||||||
scope,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: { mode },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
|
|
||||||
method: 'POST',
|
|
||||||
body: { approvalRef },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async request<T>(
|
|
||||||
path: string,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
init: { method?: string; body?: unknown } = {},
|
|
||||||
): Promise<T> {
|
|
||||||
if (!this.baseUrl || !this.serviceToken) {
|
|
||||||
throw new Error(
|
|
||||||
'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const endpoint = new URL(this.baseUrl);
|
|
||||||
if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) {
|
|
||||||
throw new Error('Hermes runtime transport requires HTTPS outside loopback');
|
|
||||||
}
|
|
||||||
const response = await this.fetchFn(
|
|
||||||
new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`),
|
|
||||||
{
|
|
||||||
method: init.method ?? 'GET',
|
|
||||||
headers: {
|
|
||||||
accept: 'application/json',
|
|
||||||
authorization: `Bearer ${this.serviceToken}`,
|
|
||||||
'x-mosaic-actor-id': scope.actorId,
|
|
||||||
'x-mosaic-tenant-id': scope.tenantId,
|
|
||||||
'x-mosaic-channel-id': scope.channelId,
|
|
||||||
'x-correlation-id': scope.correlationId,
|
|
||||||
...(init.body ? { 'content-type': 'application/json' } : {}),
|
|
||||||
},
|
|
||||||
...(init.body ? { body: JSON.stringify(init.body) } : {}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`);
|
|
||||||
if (response.status === 204) return undefined as T;
|
|
||||||
return (await response.json()) as T;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoopbackHttp(endpoint: URL): boolean {
|
|
||||||
return (
|
|
||||||
endpoint.protocol === 'http:' &&
|
|
||||||
(endpoint.hostname === 'localhost' ||
|
|
||||||
endpoint.hostname === '127.0.0.1' ||
|
|
||||||
endpoint.hostname === '::1')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,6 @@
|
|||||||
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import {
|
import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js';
|
||||||
RuntimeApprovalDeniedError,
|
|
||||||
RuntimeProviderService,
|
|
||||||
} from './runtime-provider-registry.service.js';
|
|
||||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
import { InteractionController } from './interaction.controller.js';
|
import { InteractionController } from './interaction.controller.js';
|
||||||
|
|
||||||
@@ -43,32 +39,6 @@ describe('InteractionController', (): void => {
|
|||||||
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => {
|
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
|
||||||
const registry = createGatewayRuntimeProviderRegistry();
|
|
||||||
const runtime = new RuntimeProviderService(
|
|
||||||
registry,
|
|
||||||
{ record: vi.fn().mockResolvedValue(undefined) },
|
|
||||||
{ consume: vi.fn().mockResolvedValue(false) },
|
|
||||||
);
|
|
||||||
const controller = new InteractionController(runtime, {} as never);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
controller.transitionalCapabilities(
|
|
||||||
'Nova',
|
|
||||||
'runtime.hermes',
|
|
||||||
{ id: 'owner', tenantId: 'team' },
|
|
||||||
'corr-1',
|
|
||||||
),
|
|
||||||
).resolves.toEqual([
|
|
||||||
{ capability: 'kanban', status: 'unsupported' },
|
|
||||||
{ capability: 'skills', status: 'unsupported' },
|
|
||||||
{ capability: 'memory', status: 'unsupported' },
|
|
||||||
{ capability: 'tools', status: 'unsupported' },
|
|
||||||
{ capability: 'cron', status: 'unsupported' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a request without the non-simple correlation header', async () => {
|
it('rejects a request without the non-simple correlation header', async () => {
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
|
|||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
import { AuthGuard } from '../auth/auth.guard.js';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator.js';
|
import { CurrentUser } from '../auth/current-user.decorator.js';
|
||||||
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
export class InteractionController {
|
export class InteractionController {
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
|
||||||
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
|
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
@@ -51,20 +51,6 @@ export class InteractionController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('transitional-capabilities')
|
|
||||||
async transitionalCapabilities(
|
|
||||||
@Param('agentName') agentName: string,
|
|
||||||
@Query('provider') providerId: string,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
) {
|
|
||||||
this.assertConfiguredAgent(agentName);
|
|
||||||
return this.runtime.transitionalCapabilityMatrix(
|
|
||||||
this.requiredProvider(providerId),
|
|
||||||
this.context(user, correlationId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tree')
|
@Get('tree')
|
||||||
async tree(
|
async tree(
|
||||||
@Param('agentName') agentName: string,
|
@Param('agentName') agentName: string,
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import type {
|
|||||||
RuntimeSession,
|
RuntimeSession,
|
||||||
RuntimeSessionTree,
|
RuntimeSessionTree,
|
||||||
RuntimeStreamEvent,
|
RuntimeStreamEvent,
|
||||||
TransitionalCapabilityInventoryEntry,
|
|
||||||
TransitionalCapabilityInventoryProvider,
|
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.js';
|
import { LOG_SERVICE } from '../log/log.tokens.js';
|
||||||
@@ -30,8 +28,7 @@ export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
|||||||
export type RuntimeProviderOperation =
|
export type RuntimeProviderOperation =
|
||||||
| RuntimeCapability
|
| RuntimeCapability
|
||||||
| 'runtime.capabilities'
|
| 'runtime.capabilities'
|
||||||
| 'runtime.health'
|
| 'runtime.health';
|
||||||
| 'runtime.transitional-capabilities';
|
|
||||||
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
|
|
||||||
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
||||||
@@ -74,15 +71,6 @@ export interface RuntimeApprovalVerifier {
|
|||||||
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransitionalInventoryProvider(
|
|
||||||
provider: AgentRuntimeProvider,
|
|
||||||
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
|
|
||||||
return (
|
|
||||||
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
|
|
||||||
.transitionalCapabilityMatrix === 'function'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function configuredAgentName(): string {
|
function configuredAgentName(): string {
|
||||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
if (!agentName) throw new RuntimeApprovalDeniedError();
|
if (!agentName) throw new RuntimeApprovalDeniedError();
|
||||||
@@ -163,25 +151,6 @@ export class RuntimeProviderService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async transitionalCapabilityMatrix(
|
|
||||||
providerId: string,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
|
||||||
return this.execute(
|
|
||||||
providerId,
|
|
||||||
'runtime.transitional-capabilities',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
context,
|
|
||||||
async (provider: AgentRuntimeProvider, scope: RuntimeScope) => {
|
|
||||||
if (!isTransitionalInventoryProvider(provider)) {
|
|
||||||
throw new NotFoundException('Runtime provider has no transitional capability inventory');
|
|
||||||
}
|
|
||||||
return provider.transitionalCapabilityMatrix(scope);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listSessions(
|
async listSessions(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
context: RuntimeProviderRequestContext,
|
context: RuntimeProviderRequestContext,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/** Server-side request for a replay-safe provider message. */
|
/** Server-side request for a replay-safe provider message. */
|
||||||
export interface ProviderOutboxDto {
|
export interface TessProviderOutboxDto {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
correlationId: string;
|
correlationId: string;
|
||||||
@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
|
|||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
import { DurableSessionService } from './durable-session.service.js';
|
import { TessDurableSessionService } from './tess-durable-session.service.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
|
|||||||
runtimeSessionId: 'nova',
|
runtimeSessionId: 'nova',
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('DurableSessionRepository', () => {
|
describe('TessDurableSessionRepository', () => {
|
||||||
let dataDir: string | undefined;
|
let dataDir: string | undefined;
|
||||||
let handle: DbHandle;
|
let handle: DbHandle;
|
||||||
let previousAuthSecret: string | undefined;
|
let previousAuthSecret: string | undefined;
|
||||||
@@ -48,7 +48,9 @@ describe('DurableSessionRepository', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
it('survives a full PGlite close/reopen mid-session without duplicate inbox or outbox side effects', async () => {
|
||||||
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const beforeRestart = new DurableSessionCoordinator(
|
||||||
|
new TessDurableSessionRepository(handle.db),
|
||||||
|
);
|
||||||
await beforeRestart.create(IDENTITY);
|
await beforeRestart.create(IDENTITY);
|
||||||
await beforeRestart.receive({
|
await beforeRestart.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -90,7 +92,7 @@ describe('DurableSessionRepository', () => {
|
|||||||
await handle.close();
|
await handle.close();
|
||||||
handle = createPgliteDb(dataDir!);
|
handle = createPgliteDb(dataDir!);
|
||||||
|
|
||||||
const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||||
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
const resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
|
||||||
const handled: string[] = [];
|
const handled: string[] = [];
|
||||||
@@ -118,7 +120,7 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('redacts sensitive durable payloads before persistence', async () => {
|
it('redacts sensitive durable payloads before persistence', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -155,7 +157,7 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
it('fails closed when the configured idempotency secret is unavailable', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const secret = process.env['BETTER_AUTH_SECRET'];
|
const secret = process.env['BETTER_AUTH_SECRET'];
|
||||||
delete process.env['BETTER_AUTH_SECRET'];
|
delete process.env['BETTER_AUTH_SECRET'];
|
||||||
@@ -175,7 +177,7 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
it('uses keyed pre-redaction digests to reject distinct sensitive checkpoint payloads', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -225,7 +227,7 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
it('rejects distinct sensitive inbox and outbox payloads under reused idempotency keys', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
const inbox = {
|
const inbox = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -253,7 +255,7 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
it('rejects database inbox and outbox idempotency-key conflicts', async () => {
|
||||||
const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
|
const coordinator = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db));
|
||||||
await coordinator.create(IDENTITY);
|
await coordinator.create(IDENTITY);
|
||||||
await coordinator.receive({
|
await coordinator.receive({
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
@@ -291,10 +293,10 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
it('does not requeue a live outbox claim during a normal scoped dispatch', async () => {
|
||||||
const repository = new DurableSessionRepository(handle.db);
|
const repository = new TessDurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'live-effect',
|
idempotencyKey: 'live-effect',
|
||||||
@@ -331,10 +333,10 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
it('rejects an outbox correlation mismatch before claiming the pending effect', async () => {
|
||||||
const repository = new DurableSessionRepository(handle.db);
|
const repository = new TessDurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||||
const input = {
|
const input = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'mismatch-effect',
|
idempotencyKey: 'mismatch-effect',
|
||||||
@@ -364,10 +366,10 @@ describe('DurableSessionRepository', () => {
|
|||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
it('dispatches only the outbox record bound to the supplied correlation and channel', async () => {
|
||||||
const repository = new DurableSessionRepository(handle.db);
|
const repository = new TessDurableSessionRepository(handle.db);
|
||||||
const coordinator = new DurableSessionCoordinator(repository);
|
const coordinator = new DurableSessionCoordinator(repository);
|
||||||
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
const runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
|
||||||
const service = new DurableSessionService(repository, runtimeProviders as never);
|
const service = new TessDurableSessionService(repository, runtimeProviders as never);
|
||||||
const first = {
|
const first = {
|
||||||
sessionId: IDENTITY.sessionId,
|
sessionId: IDENTITY.sessionId,
|
||||||
idempotencyKey: 'scoped-effect-one',
|
idempotencyKey: 'scoped-effect-one',
|
||||||
@@ -33,7 +33,7 @@ import type {
|
|||||||
import { DB } from '../database/database.module.js';
|
import { DB } from '../database/database.module.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DurableSessionRepository implements DurableSessionStore {
|
export class TessDurableSessionRepository implements DurableSessionStore {
|
||||||
constructor(@Inject(DB) private readonly db: Db) {}
|
constructor(@Inject(DB) private readonly db: Db) {}
|
||||||
|
|
||||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||||
@@ -51,7 +51,7 @@ export class DurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.session(identity.sessionId);
|
const existing = await this.session(identity.sessionId);
|
||||||
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
if (!existing || !sameEnrollmentScope(existing, identity)) {
|
||||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||||
}
|
}
|
||||||
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
// A recovered/re-enrolled runtime can receive a new provider session ID;
|
||||||
// the conversation handle and owner scope remain immutable.
|
// the conversation handle and owner scope remain immutable.
|
||||||
@@ -135,13 +135,13 @@ export class DurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0]) throw new Error(`Durable inbox enqueue failed: ${input.idempotencyKey}`);
|
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
const entry = toInbox(existing[0]);
|
const entry = toInbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameInbox(entry, record) ||
|
!sameInbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: entry.status };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -224,13 +224,14 @@ export class DurableSessionRepository implements DurableSessionStore {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
|
if (!existing[0])
|
||||||
|
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
|
||||||
const entry = toOutbox(existing[0]);
|
const entry = toOutbox(existing[0]);
|
||||||
if (
|
if (
|
||||||
!sameOutbox(entry, record) ||
|
!sameOutbox(entry, record) ||
|
||||||
!matchesContentDigest(existing[0].contentDigest, input.content)
|
!matchesContentDigest(existing[0].contentDigest, input.content)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: entry.status };
|
return { accepted: false, status: entry.status };
|
||||||
}
|
}
|
||||||
@@ -337,7 +338,7 @@ export class DurableSessionRepository implements DurableSessionStore {
|
|||||||
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
|
||||||
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
!matchesCheckpointDigest(existing[0].contentDigest, digest)
|
||||||
) {
|
) {
|
||||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,7 +360,7 @@ export class DurableSessionRepository implements DurableSessionStore {
|
|||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
|
||||||
if (!checkpoint) {
|
if (!checkpoint) {
|
||||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
const inserted = await this.db
|
const inserted = await this.db
|
||||||
.insert(interactionHandoffs)
|
.insert(interactionHandoffs)
|
||||||
@@ -370,7 +371,7 @@ export class DurableSessionRepository implements DurableSessionStore {
|
|||||||
|
|
||||||
const existing = await this.findHandoff(input.handoffId);
|
const existing = await this.findHandoff(input.handoffId);
|
||||||
if (!existing || !sameHandoff(existing, input)) {
|
if (!existing || !sameHandoff(existing, input)) {
|
||||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
import { ForbiddenException, Inject, Injectable } from '@nestjs/common';
|
||||||
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
|
||||||
import type { ProviderOutboxDto } from './durable-session.dto.js';
|
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
|
||||||
import { DurableSessionRepository } from './durable-session.repository.js';
|
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
|
||||||
import {
|
import {
|
||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeProviderRequestContext,
|
type RuntimeProviderRequestContext,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
|
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
|
||||||
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
* uses composition: raw state methods cannot be injected into channel, CLI, or
|
||||||
* MCP adapters without a server-derived actor/tenant/correlation context.
|
* MCP adapters without a server-derived actor/tenant/correlation context.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DurableSessionService {
|
export class TessDurableSessionService {
|
||||||
private readonly coordinator: DurableSessionCoordinator;
|
private readonly coordinator: DurableSessionCoordinator;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
|
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
|
||||||
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
|
||||||
) {
|
) {
|
||||||
this.coordinator = new DurableSessionCoordinator(repository);
|
this.coordinator = new DurableSessionCoordinator(repository);
|
||||||
@@ -32,12 +32,12 @@ export class DurableSessionService {
|
|||||||
identity.ownerId !== context.actorScope.userId ||
|
identity.ownerId !== context.actorScope.userId ||
|
||||||
identity.tenantId !== context.actorScope.tenantId
|
identity.tenantId !== context.actorScope.tenantId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable session enrollment scope mismatch');
|
throw new ForbiddenException('Durable Tess enrollment scope mismatch');
|
||||||
}
|
}
|
||||||
await this.coordinator.create(identity);
|
await this.coordinator.create(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
|
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
const snapshot = await this.coordinator.snapshot(input.sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.enqueueOutbox({
|
await this.coordinator.enqueueOutbox({
|
||||||
@@ -50,9 +50,9 @@ export class DurableSessionService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||||
if (sessionId !== input.sessionId) {
|
if (sessionId !== input.sessionId) {
|
||||||
throw new ForbiddenException('Durable outbox session mismatch');
|
throw new ForbiddenException('Durable Tess outbox session mismatch');
|
||||||
}
|
}
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
@@ -92,7 +92,7 @@ export class DurableSessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
/** Startup/recovery-only path; normal queue/dispatch methods never requeue live work. */
|
||||||
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
|
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
|
||||||
const snapshot = await this.coordinator.snapshot(sessionId);
|
const snapshot = await this.coordinator.snapshot(sessionId);
|
||||||
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
|
||||||
await this.coordinator.recover(sessionId);
|
await this.coordinator.recover(sessionId);
|
||||||
@@ -100,24 +100,24 @@ export class DurableSessionService {
|
|||||||
|
|
||||||
private assertOutboxScope(
|
private assertOutboxScope(
|
||||||
entry: { kind: string; correlationId: string; channelId: string },
|
entry: { kind: string; correlationId: string; channelId: string },
|
||||||
input: ProviderOutboxDto,
|
input: TessProviderOutboxDto,
|
||||||
): void {
|
): void {
|
||||||
if (
|
if (
|
||||||
entry.kind !== 'provider.send' ||
|
entry.kind !== 'provider.send' ||
|
||||||
entry.correlationId !== input.correlationId ||
|
entry.correlationId !== input.correlationId ||
|
||||||
entry.channelId !== input.context.channelId
|
entry.channelId !== input.context.channelId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable outbox scope or correlation mismatch');
|
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): void {
|
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void {
|
||||||
if (
|
if (
|
||||||
input.context.actorScope.userId !== ownerId ||
|
input.context.actorScope.userId !== ownerId ||
|
||||||
input.context.actorScope.tenantId !== tenantId ||
|
input.context.actorScope.tenantId !== tenantId ||
|
||||||
input.context.correlationId !== input.correlationId
|
input.context.correlationId !== input.correlationId
|
||||||
) {
|
) {
|
||||||
throw new ForbiddenException('Durable session scope or correlation mismatch');
|
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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,11 +1,7 @@
|
|||||||
import { Type } from '@sinclair/typebox';
|
import { Type } from '@sinclair/typebox';
|
||||||
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
import type { ToolDefinition } from '@mariozechner/pi-coding-agent';
|
||||||
import type {
|
import type { Memory } from '@mosaicstack/memory';
|
||||||
EmbeddingProvider,
|
import type { EmbeddingProvider } from '@mosaicstack/memory';
|
||||||
Memory,
|
|
||||||
OperatorMemoryPlugin,
|
|
||||||
OperatorMemoryScope,
|
|
||||||
} from '@mosaicstack/memory';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create memory tools bound to the session's authenticated userId.
|
* Create memory tools bound to the session's authenticated userId.
|
||||||
@@ -17,10 +13,8 @@ import type {
|
|||||||
export function createMemoryTools(
|
export function createMemoryTools(
|
||||||
memory: Memory,
|
memory: Memory,
|
||||||
embeddingProvider: EmbeddingProvider | null,
|
embeddingProvider: EmbeddingProvider | null,
|
||||||
/** Authenticated user ID from the session. All preference operations are scoped to this user. */
|
/** Authenticated user ID from the session. All memory operations are scoped to this user. */
|
||||||
sessionUserId: string | undefined,
|
sessionUserId: string | undefined,
|
||||||
/** Optional configured retrieval plugin, bound to a server-derived session scope. */
|
|
||||||
operatorMemory?: { plugin: OperatorMemoryPlugin; scope: OperatorMemoryScope },
|
|
||||||
): ToolDefinition[] {
|
): ToolDefinition[] {
|
||||||
/** Return an error result when no session user is bound. */
|
/** Return an error result when no session user is bound. */
|
||||||
function noUserError() {
|
function noUserError() {
|
||||||
@@ -52,14 +46,6 @@ export function createMemoryTools(
|
|||||||
limit?: number;
|
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) {
|
if (!embeddingProvider) {
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
@@ -172,18 +158,6 @@ export function createMemoryTools(
|
|||||||
};
|
};
|
||||||
type Cat = 'decision' | 'learning' | 'preference' | 'fact' | 'pattern' | 'general';
|
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;
|
let embedding: number[] | null = null;
|
||||||
if (embeddingProvider) {
|
if (embeddingProvider) {
|
||||||
embedding = await embeddingProvider.embed(content);
|
embedding = await embeddingProvider.embed(content);
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
type RuntimeAuditSink,
|
type RuntimeAuditSink,
|
||||||
} from '../agent/runtime-provider-registry.service.js';
|
} from '../agent/runtime-provider-registry.service.js';
|
||||||
import { DurableSessionService } from '../agent/durable-session.service.js';
|
import { TessDurableSessionService } from '../agent/tess-durable-session.service.js';
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
import { AUTH } from '../auth/auth.tokens.js';
|
||||||
import {
|
import {
|
||||||
scopeFromUser,
|
scopeFromUser,
|
||||||
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
|
|||||||
@Inject(RuntimeProviderService)
|
@Inject(RuntimeProviderService)
|
||||||
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
private readonly runtimeRegistry: RuntimeProviderService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(DurableSessionService)
|
@Inject(TessDurableSessionService)
|
||||||
private readonly durableSessions: DurableSessionService | null = null,
|
private readonly durableSessions: TessDurableSessionService | null = null,
|
||||||
@Optional()
|
@Optional()
|
||||||
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
private readonly runtimeAudit: RuntimeAuditSink | null = null,
|
||||||
|
|||||||
@@ -1,32 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
|
|
||||||
import { CoordService } from './coord.service.js';
|
import { CoordService } from './coord.service.js';
|
||||||
import { CoordController } from './coord.controller.js';
|
import { CoordController } from './coord.controller.js';
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
|
||||||
import {
|
|
||||||
COORDINATION_CONFIG,
|
|
||||||
COORDINATION_PORT,
|
|
||||||
InteractionCoordinationService,
|
|
||||||
} from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
providers: [CoordService],
|
||||||
CoordService,
|
controllers: [CoordController],
|
||||||
{
|
exports: [CoordService],
|
||||||
provide: COORDINATION_PORT,
|
|
||||||
useFactory: (): InMemoryInteractionCoordinationPort =>
|
|
||||||
new InMemoryInteractionCoordinationPort(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: COORDINATION_CONFIG,
|
|
||||||
useFactory: () => ({
|
|
||||||
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
|
|
||||||
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
InteractionCoordinationService,
|
|
||||||
],
|
|
||||||
controllers: [CoordController, InteractionCoordinationController],
|
|
||||||
exports: [CoordService, InteractionCoordinationService],
|
|
||||||
})
|
})
|
||||||
export class CoordModule {}
|
export class CoordModule {}
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
const PATH_METADATA = 'path';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
|
||||||
|
|
||||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
|
||||||
|
|
||||||
describe('InteractionCoordinationController', () => {
|
|
||||||
it('exposes the neutral canonical route and Mos compatibility alias over identical handlers', () => {
|
|
||||||
expect(Reflect.getMetadata(PATH_METADATA, InteractionCoordinationController)).toEqual([
|
|
||||||
'api/coord/interaction',
|
|
||||||
'api/coord/mos',
|
|
||||||
]);
|
|
||||||
expect(InteractionCoordinationController.prototype.handoff).toBeTypeOf('function');
|
|
||||||
expect(InteractionCoordinationController.prototype.observe).toBeTypeOf('function');
|
|
||||||
expect(InteractionCoordinationController.prototype.result).toBeTypeOf('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
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 InteractionCoordinationController(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 InteractionCoordinationController(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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 {
|
|
||||||
InteractionCoordinationObservationDto,
|
|
||||||
InteractionCoordinationResponseDto,
|
|
||||||
InteractionCoordinationResultDto,
|
|
||||||
CreateHandoffDto,
|
|
||||||
} from './interaction-coordination.dto.js';
|
|
||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
|
||||||
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
|
||||||
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
|
||||||
@UseGuards(AuthGuard)
|
|
||||||
export class InteractionCoordinationController {
|
|
||||||
constructor(
|
|
||||||
@Inject(InteractionCoordinationService)
|
|
||||||
private readonly coordination: InteractionCoordinationService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Post('handoff')
|
|
||||||
async handoff(
|
|
||||||
@Body() request: CreateHandoffDto,
|
|
||||||
@CurrentUser() user: AuthenticatedUserLike,
|
|
||||||
@Headers('x-correlation-id') correlationId?: string,
|
|
||||||
): Promise<InteractionCoordinationResponseDto> {
|
|
||||||
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<InteractionCoordinationObservationDto> {
|
|
||||||
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<InteractionCoordinationResultDto> {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type {
|
|
||||||
CoordinationObservation,
|
|
||||||
CoordinationResult,
|
|
||||||
HandoffReceipt,
|
|
||||||
} from '@mosaicstack/coord';
|
|
||||||
|
|
||||||
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
|
|
||||||
export interface CreateHandoffDto {
|
|
||||||
idempotencyKey: string;
|
|
||||||
summary: string;
|
|
||||||
context?: string;
|
|
||||||
missionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InteractionCoordinationResponseDto {
|
|
||||||
receipt: HandoffReceipt;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InteractionCoordinationObservationDto {
|
|
||||||
observation: CoordinationObservation;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InteractionCoordinationResultDto {
|
|
||||||
result: CoordinationResult;
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { Global, Module } from '@nestjs/common';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
|
||||||
import { AUTH } from '../auth/auth.tokens.js';
|
|
||||||
import { AuthGuard } from '../auth/auth.guard.js';
|
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
|
||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
@Global()
|
|
||||||
@Module({
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: AUTH,
|
|
||||||
useValue: {
|
|
||||||
api: {
|
|
||||||
getSession: vi.fn(async ({ headers }: { headers: Headers }) =>
|
|
||||||
headers.get('cookie') === 'session=trusted'
|
|
||||||
? { user: { id: 'operator-1', tenantId: 'tenant-1' }, session: { id: 'session-1' } }
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
AuthGuard,
|
|
||||||
],
|
|
||||||
exports: [AUTH, AuthGuard],
|
|
||||||
})
|
|
||||||
class AuthenticatedRequestModule {}
|
|
||||||
|
|
||||||
describe('InteractionCoordinationController route aliases', (): void => {
|
|
||||||
let app: NestFastifyApplication | undefined;
|
|
||||||
const coordination = {
|
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
|
||||||
observe: vi.fn(async () => ({ status: 'running' })),
|
|
||||||
result: vi.fn(async () => ({ status: 'completed' })),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeAll(async (): Promise<void> => {
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
imports: [AuthenticatedRequestModule],
|
|
||||||
controllers: [InteractionCoordinationController],
|
|
||||||
providers: [{ provide: InteractionCoordinationService, useValue: coordination }],
|
|
||||||
}).compile();
|
|
||||||
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
|
|
||||||
await app.init();
|
|
||||||
await app.getHttpAdapter().getInstance().ready();
|
|
||||||
});
|
|
||||||
afterAll(async (): Promise<void> => app?.close());
|
|
||||||
|
|
||||||
it('routes handoff, observe, and result through the same AuthGuard-protected service for both prefixes', async (): Promise<void> => {
|
|
||||||
if (!app) throw new Error('test app was not initialized');
|
|
||||||
for (const prefix of ['/api/coord/interaction', '/api/coord/mos']) {
|
|
||||||
const headers = { cookie: 'session=trusted', 'x-correlation-id': `corr-${prefix}` };
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: `${prefix}/handoff`,
|
|
||||||
headers,
|
|
||||||
payload: { idempotencyKey: `key-${prefix}`, summary: 'handoff' },
|
|
||||||
})
|
|
||||||
).statusCode,
|
|
||||||
).toBe(201);
|
|
||||||
expect(
|
|
||||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/observe`, headers }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
expect(
|
|
||||||
(await app.inject({ method: 'GET', url: `${prefix}/handoff-1/result`, headers }))
|
|
||||||
.statusCode,
|
|
||||||
).toBe(200);
|
|
||||||
}
|
|
||||||
expect(coordination.handoff).toHaveBeenCalledTimes(2);
|
|
||||||
expect(coordination.observe).toHaveBeenCalledTimes(2);
|
|
||||||
expect(coordination.result).toHaveBeenCalledTimes(2);
|
|
||||||
expect(
|
|
||||||
(
|
|
||||||
await app.inject({
|
|
||||||
method: 'POST',
|
|
||||||
url: '/api/coord/interaction/handoff',
|
|
||||||
payload: { idempotencyKey: 'denied', summary: 'x' },
|
|
||||||
})
|
|
||||||
).statusCode,
|
|
||||||
).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
InMemoryInteractionCoordinationPort,
|
|
||||||
type InteractionCoordinationPort,
|
|
||||||
type Handoff,
|
|
||||||
} from '@mosaicstack/coord';
|
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
|
||||||
import {
|
|
||||||
InteractionCoordinationService,
|
|
||||||
type InteractionCoordinationConfig,
|
|
||||||
type InteractionCoordinationGatewayError,
|
|
||||||
} from './interaction-coordination.service.js';
|
|
||||||
|
|
||||||
const context: RuntimeProviderRequestContext = {
|
|
||||||
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'corr-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const config: InteractionCoordinationConfig = {
|
|
||||||
interactionAgentId: 'Nova',
|
|
||||||
orchestrationAgentId: 'Conductor',
|
|
||||||
};
|
|
||||||
|
|
||||||
function service(
|
|
||||||
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
|
|
||||||
options: {
|
|
||||||
config?: InteractionCoordinationConfig;
|
|
||||||
handoffIdFactory?: () => string;
|
|
||||||
} = {},
|
|
||||||
): InteractionCoordinationService {
|
|
||||||
return new InteractionCoordinationService(
|
|
||||||
port,
|
|
||||||
options.config ?? config,
|
|
||||||
options.handoffIdFactory ?? (() => 'handoff-1'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('InteractionCoordinationService authority boundary', (): void => {
|
|
||||||
it('derives identity and actor/tenant scope server-side, then round-trips the native adapter', async (): Promise<void> => {
|
|
||||||
const adapter = new InMemoryInteractionCoordinationPort();
|
|
||||||
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', 'Orchestrator accepted the request');
|
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
|
||||||
|
|
||||||
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 orchestrator',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
|
|
||||||
const adapter = new InMemoryInteractionCoordinationPort();
|
|
||||||
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<InteractionCoordinationGatewayError>);
|
|
||||||
expect(handoff).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
|
|
||||||
const adapter = new InMemoryInteractionCoordinationPort();
|
|
||||||
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 InMemoryInteractionCoordinationPort();
|
|
||||||
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<InteractionCoordinationGatewayError>);
|
|
||||||
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
|
|
||||||
code: 'cross_tenant_forbidden',
|
|
||||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
||||||
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: InteractionCoordinationPort = {
|
|
||||||
handoff: vi.fn(async (handoff: Handoff) => {
|
|
||||||
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 InMemoryInteractionCoordinationPort();
|
|
||||||
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<InteractionCoordinationGatewayError>);
|
|
||||||
await expect(
|
|
||||||
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'invalid_request',
|
|
||||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
||||||
await expect(
|
|
||||||
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'invalid_request',
|
|
||||||
} satisfies Partial<InteractionCoordinationGatewayError>);
|
|
||||||
expect(handoff).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when the port reports a target that drifts from configuration', async (): Promise<void> => {
|
|
||||||
const adapter: InteractionCoordinationPort = {
|
|
||||||
handoff: vi.fn(async (handoff: Handoff) => ({
|
|
||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
|
||||||
import {
|
|
||||||
InteractionCoordinationClient,
|
|
||||||
type CoordinationObservation,
|
|
||||||
type CoordinationResult,
|
|
||||||
type CoordinationScope,
|
|
||||||
type InteractionCoordinationIdentity,
|
|
||||||
type InteractionCoordinationPort,
|
|
||||||
type HandoffReceipt,
|
|
||||||
} from '@mosaicstack/coord';
|
|
||||||
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
|
|
||||||
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
|
|
||||||
|
|
||||||
export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
|
|
||||||
export const COORDINATION_CONFIG = Symbol('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 InteractionCoordinationConfig {
|
|
||||||
interactionAgentId?: string;
|
|
||||||
orchestrationAgentId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffOwner {
|
|
||||||
actorId: string;
|
|
||||||
tenantId: string;
|
|
||||||
requesterAgentId: string;
|
|
||||||
correlationId: string;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NormalizedHandoffRequest {
|
|
||||||
idempotencyKey: string;
|
|
||||||
summary: string;
|
|
||||||
context?: string;
|
|
||||||
missionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrackedHandoff {
|
|
||||||
request: NormalizedHandoffRequest;
|
|
||||||
receipt: Promise<HandoffReceipt>;
|
|
||||||
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 orchestrator-owned orchestration verbs.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class InteractionCoordinationService {
|
|
||||||
private readonly owners = new Map<string, HandoffOwner>();
|
|
||||||
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
|
|
||||||
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
|
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async handoff(
|
|
||||||
request: CreateHandoffDto,
|
|
||||||
context: RuntimeProviderRequestContext,
|
|
||||||
): Promise<HandoffReceipt> {
|
|
||||||
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 InteractionCoordinationGatewayError(
|
|
||||||
'handoff_conflict',
|
|
||||||
'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: NormalizedHandoffRequest,
|
|
||||||
scope: CoordinationScope,
|
|
||||||
): Promise<HandoffReceipt> {
|
|
||||||
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 InteractionCoordinationGatewayError(
|
|
||||||
'handoff_conflict',
|
|
||||||
'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): InteractionCoordinationClient {
|
|
||||||
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
|
|
||||||
}
|
|
||||||
|
|
||||||
private identity(): InteractionCoordinationIdentity {
|
|
||||||
const interactionAgentId = this.config.interactionAgentId?.trim();
|
|
||||||
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
|
|
||||||
if (!interactionAgentId) {
|
|
||||||
throw new InteractionCoordinationGatewayError(
|
|
||||||
'unconfigured_requester',
|
|
||||||
'Interaction agent identity is not configured',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!orchestrationAgentId) {
|
|
||||||
throw new InteractionCoordinationGatewayError(
|
|
||||||
'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: CreateHandoffDto): NormalizedHandoffRequest {
|
|
||||||
if (typeof request !== 'object' || request === null) {
|
|
||||||
throw new InteractionCoordinationGatewayError(
|
|
||||||
'invalid_request',
|
|
||||||
'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 InteractionCoordinationGatewayError(
|
|
||||||
'invalid_request',
|
|
||||||
`Handoff ${field} must be a string`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const normalized = value.trim();
|
|
||||||
if (normalized.length === 0 || normalized.length > maximumLength) {
|
|
||||||
throw new InteractionCoordinationGatewayError(
|
|
||||||
'invalid_request',
|
|
||||||
`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 InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
owner.tenantId !== scope.tenantId ||
|
|
||||||
owner.actorId !== scope.actorId ||
|
|
||||||
owner.requesterAgentId !== scope.requesterAgentId
|
|
||||||
) {
|
|
||||||
throw new InteractionCoordinationGatewayError(
|
|
||||||
'cross_tenant_forbidden',
|
|
||||||
'Handoff is outside the authenticated scope',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return owner;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InteractionCoordinationGatewayErrorCode =
|
|
||||||
| '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: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
|
|
||||||
return (
|
|
||||||
left.idempotencyKey === right.idempotencyKey &&
|
|
||||||
left.summary === right.summary &&
|
|
||||||
left.context === right.context &&
|
|
||||||
left.missionId === right.missionId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InteractionCoordinationGatewayError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly code: InteractionCoordinationGatewayErrorCode,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = InteractionCoordinationGatewayError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,8 @@ import {
|
|||||||
createMemory,
|
createMemory,
|
||||||
type Memory,
|
type Memory,
|
||||||
createMemoryAdapter,
|
createMemoryAdapter,
|
||||||
createOperatorMemoryPlugin,
|
|
||||||
type MemoryAdapter,
|
type MemoryAdapter,
|
||||||
type MemoryConfig,
|
type MemoryConfig,
|
||||||
type OperatorMemoryPlugin,
|
|
||||||
} from '@mosaicstack/memory';
|
} from '@mosaicstack/memory';
|
||||||
import type { Db } from '@mosaicstack/db';
|
import type { Db } from '@mosaicstack/db';
|
||||||
import type { StorageAdapter } from '@mosaicstack/storage';
|
import type { StorageAdapter } from '@mosaicstack/storage';
|
||||||
@@ -16,9 +14,6 @@ import { DB, STORAGE_ADAPTER } from '../database/database.module.js';
|
|||||||
import { MEMORY } from './memory.tokens.js';
|
import { MEMORY } from './memory.tokens.js';
|
||||||
import { MemoryController } from './memory.controller.js';
|
import { MemoryController } from './memory.controller.js';
|
||||||
import { EmbeddingService } from './embedding.service.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';
|
export const MEMORY_ADAPTER = 'MEMORY_ADAPTER';
|
||||||
|
|
||||||
@@ -43,24 +38,9 @@ function buildMemoryConfig(config: MosaicConfig, storageAdapter: StorageAdapter)
|
|||||||
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
createMemoryAdapter(buildMemoryConfig(config, storageAdapter)),
|
||||||
inject: [MOSAIC_CONFIG, STORAGE_ADAPTER],
|
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,
|
EmbeddingService,
|
||||||
],
|
],
|
||||||
controllers: [MemoryController],
|
controllers: [MemoryController],
|
||||||
exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
|
exports: [MEMORY, MEMORY_ADAPTER, EmbeddingService],
|
||||||
})
|
})
|
||||||
export class MemoryModule {}
|
export class MemoryModule {}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# Documentation Sitemap
|
|
||||||
|
|
||||||
## Tess interaction agent
|
|
||||||
|
|
||||||
### Operator guides
|
|
||||||
|
|
||||||
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
|
||||||
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
|
||||||
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
|
||||||
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
|
||||||
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
|
||||||
|
|
||||||
### Architecture and security
|
|
||||||
|
|
||||||
- [Architecture](tess/ARCHITECTURE.md)
|
|
||||||
- [Threat model](tess/THREAT-MODEL.md)
|
|
||||||
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
|
||||||
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
|
||||||
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
|
||||||
|
|
||||||
### API contract
|
|
||||||
|
|
||||||
- [Tess OpenAPI contract](openapi-tess.yaml)
|
|
||||||
|
|
||||||
### Migration and qualification
|
|
||||||
|
|
||||||
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
|
||||||
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
|
||||||
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
|
||||||
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
|
||||||
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
|
||||||
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
openapi: 3.1.0
|
|
||||||
info: { title: Mosaic Tess Gateway, version: 1.0.0 }
|
|
||||||
security: [{ sessionAuth: [] }]
|
|
||||||
paths:
|
|
||||||
/api/interaction/{agentName}/sessions:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: List authorized runtime sessions,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/provider' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses: { '200': { description: Sessions } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/transitional-capabilities:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Get transitional capability matrix,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/provider' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses: { '200': { description: Matrix } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/tree:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Get authorized session tree,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/provider' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses: { '200': { description: Tree } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/sessions/{sessionId}/enroll:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Enroll a durable session,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/sessionId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Enroll' },
|
|
||||||
responses: { '200': { description: Enrolled } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/sessions/{sessionId}/attach:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Attach to a runtime session,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/sessionId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Attach' },
|
|
||||||
responses: { '200': { description: Attachment } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/sessions/{sessionId}/send:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Queue a durable provider send,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/sessionId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Send' },
|
|
||||||
responses: { '200': { description: Queued } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/sessions/{sessionId}/stop:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Stop a session with approval,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/sessionId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Stop' },
|
|
||||||
responses: { '200': { description: Stopped }, '403': { description: Approval denied } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/sessions/{sessionId}/recover:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Recover interrupted durable work,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/sessionId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses: { '200': { description: Recovered } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/coord/mos/handoff:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Submit Mos handoff,
|
|
||||||
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Handoff' },
|
|
||||||
responses: { '200': { description: Receipt } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/coord/mos/{handoffId}/observe:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Observe Mos handoff,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/handoffId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses: { '200': { description: Observation } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/coord/mos/{handoffId}/result:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Get Mos handoff result,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/handoffId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses: { '200': { description: Result } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/interaction/{agentName}/sessions/{sessionId}/stream:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Stream runtime events,
|
|
||||||
parameters:
|
|
||||||
[
|
|
||||||
{ $ref: '#/components/parameters/agentName' },
|
|
||||||
{ $ref: '#/components/parameters/sessionId' },
|
|
||||||
{ $ref: '#/components/parameters/correlation' },
|
|
||||||
],
|
|
||||||
responses:
|
|
||||||
{
|
|
||||||
'200':
|
|
||||||
{
|
|
||||||
description: Event stream,
|
|
||||||
content: { text/event-stream: { schema: { type: string } } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/memory/preferences:
|
|
||||||
{
|
|
||||||
get: { summary: List preferences, responses: { '200': { description: Preferences } } },
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Upsert preference,
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Preference' },
|
|
||||||
responses: { '200': { description: Preference } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/memory/preferences/{key}:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Get preference,
|
|
||||||
parameters: [{ $ref: '#/components/parameters/key' }],
|
|
||||||
responses: { '200': { description: Preference } },
|
|
||||||
},
|
|
||||||
delete:
|
|
||||||
{
|
|
||||||
summary: Delete preference,
|
|
||||||
parameters: [{ $ref: '#/components/parameters/key' }],
|
|
||||||
responses: { '204': { description: Deleted } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/memory/insights:
|
|
||||||
{
|
|
||||||
get: { summary: List insights, responses: { '200': { description: Insights } } },
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Create insight,
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Insight' },
|
|
||||||
responses: { '200': { description: Insight } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/memory/insights/{id}:
|
|
||||||
{
|
|
||||||
get:
|
|
||||||
{
|
|
||||||
summary: Get insight,
|
|
||||||
parameters: [{ $ref: '#/components/parameters/id' }],
|
|
||||||
responses: { '200': { description: Insight } },
|
|
||||||
},
|
|
||||||
delete:
|
|
||||||
{
|
|
||||||
summary: Delete insight,
|
|
||||||
parameters: [{ $ref: '#/components/parameters/id' }],
|
|
||||||
responses: { '204': { description: Deleted } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
/api/memory/search:
|
|
||||||
{
|
|
||||||
post:
|
|
||||||
{
|
|
||||||
summary: Search memory,
|
|
||||||
requestBody: { $ref: '#/components/requestBodies/Search' },
|
|
||||||
responses: { '200': { description: Search results } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
components:
|
|
||||||
securitySchemes: { sessionAuth: { type: http, scheme: bearer } }
|
|
||||||
parameters:
|
|
||||||
agentName: { name: agentName, in: path, required: true, schema: { type: string } }
|
|
||||||
sessionId: { name: sessionId, in: path, required: true, schema: { type: string } }
|
|
||||||
provider: { name: provider, in: query, required: true, schema: { type: string } }
|
|
||||||
correlation: { name: X-Correlation-Id, in: header, required: true, schema: { type: string } }
|
|
||||||
key: { name: key, in: path, required: true, schema: { type: string } }
|
|
||||||
id: { name: id, in: path, required: true, schema: { type: string } }
|
|
||||||
requestBodies:
|
|
||||||
Enroll:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [providerId, runtimeSessionId],
|
|
||||||
properties:
|
|
||||||
{ providerId: { type: string }, runtimeSessionId: { type: string } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Handoff:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [idempotencyKey, summary],
|
|
||||||
properties:
|
|
||||||
{
|
|
||||||
idempotencyKey: { type: string },
|
|
||||||
summary: { type: string },
|
|
||||||
context: { type: string },
|
|
||||||
missionId: { type: string },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Attach:
|
|
||||||
{
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json: { schema: { type: object, properties: { mode: { enum: [read] } } } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Send:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [content, idempotencyKey],
|
|
||||||
properties: { content: { type: string }, idempotencyKey: { type: string } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Stop:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [approvalRef],
|
|
||||||
properties: { approvalRef: { type: string } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Preference:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [key, value],
|
|
||||||
properties:
|
|
||||||
{
|
|
||||||
key: { type: string },
|
|
||||||
value: {},
|
|
||||||
category: { type: string },
|
|
||||||
source: { type: string },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Insight:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [content],
|
|
||||||
properties:
|
|
||||||
{
|
|
||||||
content: { type: string },
|
|
||||||
source: { type: string },
|
|
||||||
category: { type: string },
|
|
||||||
metadata: { type: object },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
Search:
|
|
||||||
{
|
|
||||||
required: true,
|
|
||||||
content:
|
|
||||||
{
|
|
||||||
application/json:
|
|
||||||
{
|
|
||||||
schema:
|
|
||||||
{
|
|
||||||
type: object,
|
|
||||||
required: [query],
|
|
||||||
properties:
|
|
||||||
{
|
|
||||||
query: { type: string },
|
|
||||||
limit: { type: integer },
|
|
||||||
maxDistance: { type: number },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# #747 — De-hardcode orchestrator and interaction agent names
|
|
||||||
|
|
||||||
## Objective
|
|
||||||
|
|
||||||
Replace branded Mos/Tess symbols, filenames, DI tokens, and error prose with role-neutral orchestrator/interaction vocabulary without changing env-driven runtime identity behavior. Add optional roster `alias` and `provider` fields and show aliases in `mosaic fleet ps` with name fallback.
|
|
||||||
|
|
||||||
## Scope and constraints
|
|
||||||
|
|
||||||
- Requirements: `/home/hermes/agent-work/reviews/747-wsa-dehardcode-brief.md`.
|
|
||||||
- Branch: `feat/747-dehardcode-orchestrator-interaction-names` from `main` at `e72388b2`.
|
|
||||||
- Keep `MOSAIC_AGENT_NAME` and `MOSAIC_ORCHESTRATOR_AGENT_NAME` unchanged.
|
|
||||||
- Sample/test data may retain operator display names.
|
|
||||||
- No behavior change beyond optional roster metadata and alias display.
|
|
||||||
- Budget: no explicit cap; conservative mechanical-rename scope only.
|
|
||||||
- TDD: optional and skipped because this is a mechanical rename with existing focused coverage; add focused alias/schema regression coverage before completion.
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
1. Rename coordination and durable-session files and symbols using canonical vocabulary.
|
|
||||||
2. Scrub branded symbol names and error prose in the assigned source trees while preserving allowed sample data.
|
|
||||||
3. Extend roster schema with optional `alias` and `provider`; update fleet roster typing/rendering and focused tests.
|
|
||||||
4. Run grep-clean verification, build, typecheck, lint/format, focused coord/durable-session/fleet tests, and roster validation.
|
|
||||||
5. Commit, queue-guard, push, open a Gitea PR closing #747, and report to the coordinator.
|
|
||||||
|
|
||||||
## Progress
|
|
||||||
|
|
||||||
- 2026-07-13: Task resumed from coordinator brief; repository clean at `e72388b2`.
|
|
||||||
- Renamed coordination and durable-session files, exports, gateway DI symbols, DTOs, services, repositories, and tests.
|
|
||||||
- Replaced branded authority/error prose while preserving the existing `/api/coord/mos` compatibility route and env-variable identity inputs.
|
|
||||||
- Added optional roster `alias`/`provider` support and alias-first `fleet ps` display with canonical-name fallback.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm typecheck`: passed (42 tasks).
|
|
||||||
- `pnpm build`: passed (23 tasks).
|
|
||||||
- `pnpm lint`: passed (23 tasks).
|
|
||||||
- `pnpm format:check`: passed.
|
|
||||||
- Coordination tests: 7 passed.
|
|
||||||
- Agent durable-session/runtime tests: 23 passed.
|
|
||||||
- Gateway coordination/durable-session/integration tests: 20 passed.
|
|
||||||
- Full `fleet.spec.ts`: 192 passed, including alias/provider parsing and alias display.
|
|
||||||
- JSON Schema 2020 validation: legacy minimal roster and extended alias/provider roster passed; `alias` and `provider` remain absent from `required`.
|
|
||||||
- Grep verification: no branded symbol/type/file/DI names or error prose remain in assigned source trees; one allowed `Tess Owner` test-data display name remains.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# 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 `InMemoryInteractionCoordinationPort` 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 `InteractionCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
|
||||||
- Implemented the gateway `InteractionCoordinationService`, 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.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# Tess Administration
|
|
||||||
|
|
||||||
Configure agent/provider identities outside client input. Verify `/health/ready` and provider health before enabling interaction clients. Every interaction request requires an authenticated actor and correlation header; tenant and owner scope are server-derived. Do not log or return service credentials.
|
|
||||||
|
|
||||||
For an incident, preserve correlation IDs, inspect provider status and durable checkpoint/inbox/outbox state, then use the recovery endpoint. Do not retry an ambiguous external effect automatically. Stop operations require an exact one-time approval reference; provisioning or granting a broad admin capability does not replace that check.
|
|
||||||
@@ -52,21 +52,6 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
|
|||||||
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
| Destructive, privileged, external/customer-visible action | Human approval + policy | Propose, wait for durable one-time approval, then execute idempotently |
|
||||||
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
| Provider-specific unsupported action | None | Fail closed; never emulate silently |
|
||||||
|
|
||||||
### Mos Coordination Boundary
|
|
||||||
|
|
||||||
`@mosaicstack/coord` exposes only the transport-neutral `InteractionCoordinationPort`
|
|
||||||
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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
# Tess Developer Guide
|
|
||||||
|
|
||||||
Interaction adapters pass only server-derived actor/tenant scope, channel, and correlation to runtime providers. Durable session state owns inbox/outbox/checkpoint recovery. Use the OpenAPI contract rather than inventing routes; unsupported provider capabilities fail closed.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
# TESS-M5-003 Documentation Checklist
|
|
||||||
|
|
||||||
- [x] `openapi-tess.yaml`: authenticated interaction endpoints including SSE stream, Mos handoff/observe/result, and memory preferences, insights, and search.
|
|
||||||
- [x] User guide: authorized session and handoff workflows.
|
|
||||||
- [x] Admin guide: provisioning, policy, health, and approval boundary.
|
|
||||||
- [x] Developer guide: scope, durable state, and provider adapter contract.
|
|
||||||
- [x] Plugin guide: replaceable-adapter, redaction, and identity-as-data rules.
|
|
||||||
- [x] Operations guide: readiness, recovery, ambiguous-effect safety, and tracing.
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# TESS-MIG-001 — Cutover Procedure
|
|
||||||
|
|
||||||
This procedure is evidence-bound. It does not authorize a production cutover until the M5 qualification gate records the required validation.
|
|
||||||
|
|
||||||
1. Confirm the gateway has the explicitly registered `runtime.hermes` adapter (`apps/gateway/src/agent/agent.module.ts`) and provider reachability evidence (`apps/gateway/src/agent/hermes-runtime-reachability.e2e.test.ts`).
|
|
||||||
2. Query the normalized runtime capability surface, not a Hermes API directly. Confirm the session capabilities required for the operation are advertised.
|
|
||||||
3. Query the transitional matrix through `RuntimeProviderService.transitionalCapabilityMatrix` (`apps/gateway/src/agent/runtime-provider-registry.service.ts`). Kanban, skills, memory, tools, and cron must remain `unsupported`; stop rather than route those operations through Hermes.
|
|
||||||
4. Route new memory activity through the Mosaic operator-memory plugin path; there is no landed Hermes memory import.
|
|
||||||
5. Use `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) for orchestration handoff. The interaction agent does not take configured orchestrator authority.
|
|
||||||
6. Record the qualification evidence and only then update an external deployment/channel binding through its separately authorized operational process.
|
|
||||||
|
|
||||||
No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# TESS-MIG-001 — Hermes → Mosaic Evidence Inventory
|
|
||||||
|
|
||||||
Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src/hermes-runtime-provider.ts` contains the adapter-local `HermesLegacySession` and converts it to core `RuntimeSession`; `packages/types/src/agent/agent-runtime-provider.ts` contains only normalized contracts. `apps/gateway/src/agent/agent.module.ts` explicitly registers the adapter, while `apps/gateway/src/agent/runtime-provider-registry.service.ts` exposes it only through the runtime registry.
|
|
||||||
|
|
||||||
| Reference concern | Landed Mosaic evidence | State |
|
|
||||||
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
|
|
||||||
| sessions, hierarchy, streaming, send/attach/terminate | `HermesRuntimeProvider` plus `hermes-runtime-provider.test.ts` | adapted |
|
|
||||||
| Kanban, skills, memory, tools, cron | normalized matrix in `HermesRuntimeProvider.transitionalCapabilityMatrix`; each is `unsupported` and `assertTransitionalCapability` denies before a transport call | deferred / fail-closed |
|
|
||||||
| operator memory | `packages/memory/src/operator-memory-plugin.ts`, constructed by `apps/gateway/src/memory/memory.module.ts` and session-scoped by `apps/gateway/src/agent/agent.service.ts` | native Mosaic path |
|
|
||||||
| orchestration handoff | `InteractionCoordinationService` in `apps/gateway/src/coord/interaction-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | native Mosaic path |
|
|
||||||
| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration |
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# TESS-MIG-001 — Retention and Legacy Deprecation Policy
|
|
||||||
|
|
||||||
## Retention
|
|
||||||
|
|
||||||
- Hermes is not a Mosaic persistence authority. The adapter maps runtime behavior only; it does not import or persist Hermes legacy session shapes.
|
|
||||||
- Mosaic operator memory is scoped by tenant, owner, and session in `packages/memory/src/operator-memory-plugin.ts`; gateway session ownership is derived before that plugin is made available in `apps/gateway/src/agent/agent.service.ts`.
|
|
||||||
- Existing Hermes archives remain in their source system under its existing retention policy. This project has no landed automatic transcript, profile, or preference migration.
|
|
||||||
- Any future import requires an explicit, scoped design and redaction/provenance evidence; it must not extend `packages/types` with Hermes schema.
|
|
||||||
|
|
||||||
## Deprecation
|
|
||||||
|
|
||||||
- Session adapter use remains transitional until M5 qualification demonstrates the normalized provider path.
|
|
||||||
- Kanban, skills, memory, tools, and cron are not deprecated into a Hermes bridge: they remain explicitly unsupported until their Mosaic-owned contracts are implemented and qualified.
|
|
||||||
- A future deprecation change must remove the external binding first, retain rollback evidence, and then remove the adapter in a separately reviewed code change. It must not silently replace or widen a registered provider.
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
# TESS-MIG-001 — Rollback Procedure
|
|
||||||
|
|
||||||
Rollback is configuration/binding reversal, not a database rollback: no Hermes schema migration or automatic data import is implemented by the landed adapter.
|
|
||||||
|
|
||||||
1. Stop sending new traffic to the Mosaic Hermes adapter by reverting the external runtime/channel binding through its authorized deployment process.
|
|
||||||
2. Keep the gateway registration and core contracts unchanged unless a reviewed code rollback is required; `AgentRuntimeProviderRegistry` registration is explicit and non-replacing (`packages/agent/src/runtime-provider-registry.ts`).
|
|
||||||
3. Do not replay an unsupported Kanban, skills, memory, tools, or cron operation. The transitional matrix is intentionally fail-closed.
|
|
||||||
4. Preserve Mosaic audit, session, and operator-memory records under their normal scoped retention rules; do not copy them into Hermes as a rollback shortcut.
|
|
||||||
5. For an in-flight coordination request, use the owned handoff observation/result flow in `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`); do not create a second orchestrator path.
|
|
||||||
6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover.
|
|
||||||
@@ -32,7 +32,7 @@ Ship Tess as Jason's durable Pi-native GPT-5.6 Sol high-reasoning interaction ag
|
|||||||
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
|
| TESS-M1 | #707 | Runtime contracts and security foundation | ready | AgentRuntimeProvider, normalized events/capabilities/errors, RBAC/audit contracts and contract tests merged |
|
||||||
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
|
| TESS-M2 | #708 | Durable Pi Tess service and state | not-started | GPT-5.6 Sol high service starts, resumes, checkpoints, and passes restart/compaction tests |
|
||||||
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
|
| TESS-M3 | #709 | Discord and CLI interaction surfaces | not-started | One durable session works through dedicated Discord binding and `mosaic tess`, including attach and approvals |
|
||||||
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | in-progress | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
|
| TESS-M4 | #710 | Fleet, Mos, Hermes, memory, state, and tool plugins | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end |
|
||||||
| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete |
|
| TESS-M5 | #711 | Matrix/native migration, recovery, documentation, and qualification | not-started | Transport parity, migration/rollback matrix, security review, docs, greenfield and deployment validation complete |
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
# 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 HandoffRequest {
|
|
||||||
readonly idempotencyKey: string;
|
|
||||||
readonly summary: string;
|
|
||||||
readonly context?: string;
|
|
||||||
readonly missionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HandoffReceipt {
|
|
||||||
readonly handoffId: string;
|
|
||||||
readonly targetAgentId: string;
|
|
||||||
readonly status: 'accepted' | 'queued';
|
|
||||||
readonly correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Handoff {
|
|
||||||
readonly handoffId: string;
|
|
||||||
readonly targetAgentId: string;
|
|
||||||
readonly request: HandoffRequest;
|
|
||||||
readonly scope: CoordinationScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InteractionCoordinationPort {
|
|
||||||
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
|
||||||
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.
|
|
||||||
|
|
||||||
## HTTP routes
|
|
||||||
|
|
||||||
`/api/coord/interaction` is the canonical HTTP coordination prefix for handoff, observe, and result. `/api/coord/mos` remains a backward-compatible alias with the same handlers and DTOs; new integrations use the neutral canonical prefix.
|
|
||||||
|
|
||||||
## Enforcement point
|
|
||||||
|
|
||||||
`apps/gateway` owns an `InteractionCoordinationService` (`apps/gateway/src/coord/interaction-coordination.service.ts`) 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 `InMemoryInteractionCoordinationPort` 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 `InteractionCoordinationPort`; 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.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Tess Operations and Recovery
|
|
||||||
|
|
||||||
Check `/health/ready`, provider health, and effective policy before recovery. Recover durable sessions through the interaction recovery operation; it requeues only interrupted work and does not replay ambiguous external effects. Preserve correlation IDs for incident tracing and use Mos handoff observation/result endpoints for orchestration visibility.
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Tess Plugin Authoring
|
|
||||||
|
|
||||||
Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults.
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -33,18 +33,14 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
|
|||||||
6. Every externally caused operation is replay-safe and correlated.
|
6. Every externally caused operation is replay-safe and correlated.
|
||||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
7. Provider capability absence is a denial, not an invitation to shell around it.
|
||||||
|
|
||||||
## Closed Prerequisite Findings
|
## Existing Findings That Block Tess
|
||||||
|
|
||||||
The original M1 findings below are closed by landed controls and retained for audit traceability.
|
- Command executor lacks server-side enforcement for declared scopes.
|
||||||
|
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
|
||||||
|
- MCP schemas accept caller-supplied user identity.
|
||||||
|
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
|
||||||
|
- Chat/tool persistence lacks mandatory redaction.
|
||||||
|
- Sessions/pending Discord output are in-memory and not restart-safe.
|
||||||
|
- Session GC currently performs globally scoped promotion.
|
||||||
|
|
||||||
| Former finding | Closed evidence |
|
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.
|
||||||
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. |
|
|
||||||
| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. |
|
|
||||||
| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. |
|
|
||||||
| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. |
|
|
||||||
| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. |
|
|
||||||
| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. |
|
|
||||||
| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. |
|
|
||||||
|
|
||||||
These controls remain subject to the runtime's independent review and release qualification gates.
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
# Tess User Guide
|
|
||||||
|
|
||||||
All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work.
|
|
||||||
|
|
||||||
Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID.
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
# Tess Verification Matrix
|
# Tess Verification Matrix
|
||||||
|
|
||||||
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
| Acceptance criterion | Requirements | Planned evidence | Gate |
|
||||||
| -------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ---------------- |
|
||||||
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
| AC-TESS-01 | TESS-PI-001, TESS-DSC-001, TESS-CLI-001 | Discord/CLI same-session integration and streaming E2E | M3-V |
|
||||||
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
| AC-TESS-02 | TESS-ARP-001, TESS-CLI-001, TESS-FLT-001 | CLI contract tests for status/sessions/tree/attach/send/stop, typed denial/error snapshots | M3-V |
|
||||||
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
| AC-TESS-03 | TESS-PI-001, TESS-OBS-001 | Clean service launch; status asserts GPT-5.6 Sol, high reasoning and effective tool policy with secret canaries absent | M2-V, M3-V |
|
||||||
| 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-04 | TESS-MOS-001, TESS-FLT-001 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V |
|
||||||
| AC-TESS-05 | TESS-HRM-001, 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-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V |
|
||||||
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V |
|
||||||
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
| AC-TESS-07 | TESS-SEC-001..009 | Threat-model abuse suite: authz, tenant isolation, forged identity/approval, injection, redaction, transport identity, GC scope | M1-V, M3-V, M5-V |
|
||||||
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
|
| AC-TESS-08 | TESS-TRN-001 | Common provider contract suite against tmux/fleet and Matrix/native; identity and replay tests | M5-V |
|
||||||
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
|
| AC-TESS-09 | all | `pnpm typecheck`, lint, format, unit/integration/contract/E2E; independent code and security reviews; CI URLs | Every milestone |
|
||||||
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
|
| AC-TESS-10 | TESS-MIG-001 | Completed capability inventory with native/adapted/deferred/rejected state, owner, cutover/rollback evidence | M5-V |
|
||||||
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
|
| AC-TESS-11 | TESS-PLG-001, TESS-OBS-001 | OpenAPI and user/admin/developer/plugin/ops docs, sitemap links, documentation checklist | M5-V |
|
||||||
|
|
||||||
## Security Abuse Suite Minimum
|
## Security Abuse Suite Minimum
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
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,6 +2,4 @@ export const VERSION = '0.0.0';
|
|||||||
|
|
||||||
export * from './runtime-provider-registry.js';
|
export * from './runtime-provider-registry.js';
|
||||||
export * from './tmux-fleet-runtime-provider.js';
|
export * from './tmux-fleet-runtime-provider.js';
|
||||||
export * from './hermes-runtime-provider.js';
|
export * from './tess-durable-session.js';
|
||||||
export * from './matrix-native-runtime-provider.js';
|
|
||||||
export * from './durable-session.js';
|
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { RuntimeScope, RuntimeStreamEvent } from '@mosaicstack/types';
|
|
||||||
import {
|
|
||||||
type MatrixRuntimeSession,
|
|
||||||
type MatrixRuntimeTransport,
|
|
||||||
MatrixNativeRuntimeProvider,
|
|
||||||
type MatrixReadAuthority,
|
|
||||||
type MatrixWriteAuthority,
|
|
||||||
} from './matrix-native-runtime-provider.js';
|
|
||||||
|
|
||||||
const scope: RuntimeScope = {
|
|
||||||
actorId: 'operator-1',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
channelId: 'matrix-control',
|
|
||||||
correlationId: 'corr-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const session: MatrixRuntimeSession = {
|
|
||||||
id: 'native-1',
|
|
||||||
runtimeId: '@worker:example.test',
|
|
||||||
state: 'active',
|
|
||||||
createdAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
};
|
|
||||||
|
|
||||||
function transport(): MatrixRuntimeTransport {
|
|
||||||
return {
|
|
||||||
health: vi.fn(async () => ({
|
|
||||||
status: 'healthy' as const,
|
|
||||||
checkedAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
})),
|
|
||||||
listSessions: vi.fn(async () => [session]),
|
|
||||||
verifySession: vi.fn(async (sessionId) => {
|
|
||||||
if (sessionId !== session.id) throw new Error('unexpected session');
|
|
||||||
return session;
|
|
||||||
}),
|
|
||||||
stream: vi.fn(async function* (): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
yield {
|
|
||||||
type: 'message.delta',
|
|
||||||
sessionId: 'native-1',
|
|
||||||
cursor: 'cursor-1',
|
|
||||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
content: 'hello',
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
send: vi.fn(async () => undefined),
|
|
||||||
terminate: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAuthority(): MatrixReadAuthority {
|
|
||||||
return { canRead: vi.fn(async () => true) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAuthority(): MatrixWriteAuthority {
|
|
||||||
return {
|
|
||||||
canWrite: vi.fn(async () => true),
|
|
||||||
assertAuthorized: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MatrixNativeRuntimeProvider contract boundary', (): void => {
|
|
||||||
it('advertises the concrete Matrix operations and returns only normalized sessions', async (): Promise<void> => {
|
|
||||||
const provider = new MatrixNativeRuntimeProvider({
|
|
||||||
transport: transport(),
|
|
||||||
readAuthority: readAuthority(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(provider.capabilities(scope)).resolves.toEqual({
|
|
||||||
supported: [
|
|
||||||
'session.list',
|
|
||||||
'session.tree',
|
|
||||||
'session.stream',
|
|
||||||
'session.send',
|
|
||||||
'session.attach',
|
|
||||||
'session.terminate',
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await expect(provider.listSessions(scope)).resolves.toEqual([
|
|
||||||
expect.objectContaining({ id: 'native-1', providerId: 'runtime.matrix' }),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds read attachments to immutable scope and rejects control mode before transport access', async (): Promise<void> => {
|
|
||||||
const matrix = transport();
|
|
||||||
const provider = new MatrixNativeRuntimeProvider({
|
|
||||||
transport: matrix,
|
|
||||||
readAuthority: readAuthority(),
|
|
||||||
attachmentIdFactory: () => 'attachment-1',
|
|
||||||
now: () => new Date('2026-07-13T00:00:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(provider.attach('native-1', 'control', scope)).rejects.toMatchObject({
|
|
||||||
code: 'forbidden',
|
|
||||||
});
|
|
||||||
expect(matrix.verifySession).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await provider.attach('native-1', 'read', scope);
|
|
||||||
await expect(
|
|
||||||
provider.detach('attachment-1', { ...scope, tenantId: 'other' }),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'forbidden',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates messages and applies exact Matrix authority after bound-session verification', async (): Promise<void> => {
|
|
||||||
const matrix = transport();
|
|
||||||
const writes = writeAuthority();
|
|
||||||
const provider = new MatrixNativeRuntimeProvider({
|
|
||||||
transport: matrix,
|
|
||||||
readAuthority: readAuthority(),
|
|
||||||
writeAuthority: writes,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
provider.sendMessage('native-1', { content: '', idempotencyKey: 'msg-1' }, scope),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'invalid_request',
|
|
||||||
});
|
|
||||||
expect(matrix.verifySession).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await provider.sendMessage('native-1', { content: 'hello', idempotencyKey: 'msg-1' }, scope);
|
|
||||||
expect(writes.assertAuthorized).toHaveBeenCalledWith({
|
|
||||||
operation: 'session.send',
|
|
||||||
sessionId: 'native-1',
|
|
||||||
scope,
|
|
||||||
});
|
|
||||||
expect(matrix.send).toHaveBeenCalledWith(
|
|
||||||
'native-1',
|
|
||||||
{ content: 'hello', idempotencyKey: 'msg-1' },
|
|
||||||
scope,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('streams only after exact read authorization', async (): Promise<void> => {
|
|
||||||
const matrix = transport();
|
|
||||||
const provider = new MatrixNativeRuntimeProvider({
|
|
||||||
transport: matrix,
|
|
||||||
readAuthority: readAuthority(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(collect(provider.streamSession('native-1', 'cursor-0', scope))).resolves.toEqual([
|
|
||||||
expect.objectContaining({ type: 'message.delta', sessionId: 'native-1' }),
|
|
||||||
]);
|
|
||||||
expect(matrix.stream).toHaveBeenCalledWith('native-1', 'cursor-0', scope);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
async function collect(stream: AsyncIterable<RuntimeStreamEvent>): Promise<RuntimeStreamEvent[]> {
|
|
||||||
const values: RuntimeStreamEvent[] = [];
|
|
||||||
for await (const value of stream) values.push(value);
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import type {
|
|
||||||
AgentRuntimeProvider,
|
|
||||||
RuntimeAttachHandle,
|
|
||||||
RuntimeAttachMode,
|
|
||||||
RuntimeCapabilitySet,
|
|
||||||
RuntimeHealth,
|
|
||||||
RuntimeMessage,
|
|
||||||
RuntimeScope,
|
|
||||||
RuntimeSession,
|
|
||||||
RuntimeSessionTree,
|
|
||||||
RuntimeStreamEvent,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
const MATRIX_PROVIDER_ID = 'runtime.matrix';
|
|
||||||
const ATTACHMENT_TTL_MS = 5 * 60 * 1_000;
|
|
||||||
|
|
||||||
/** A verified native runtime session. Matrix room and event details remain transport-local. */
|
|
||||||
export interface MatrixRuntimeSession {
|
|
||||||
id: string;
|
|
||||||
runtimeId: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
state: RuntimeSession['state'];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Narrow native Matrix boundary. The concrete Mosaic transport owns homeserver
|
|
||||||
* authentication, exact room mapping, Matrix identity checks, and replay cursors.
|
|
||||||
*/
|
|
||||||
export interface MatrixRuntimeTransport {
|
|
||||||
health(scope: RuntimeScope): Promise<RuntimeHealth>;
|
|
||||||
listSessions(scope: RuntimeScope): Promise<MatrixRuntimeSession[]>;
|
|
||||||
verifySession(sessionId: string, scope: RuntimeScope): Promise<MatrixRuntimeSession>;
|
|
||||||
stream(
|
|
||||||
sessionId: string,
|
|
||||||
cursor: string | undefined,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent>;
|
|
||||||
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
|
||||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MatrixRuntimeProviderErrorCode =
|
|
||||||
| 'capability_unsupported'
|
|
||||||
| 'forbidden'
|
|
||||||
| 'invalid_request'
|
|
||||||
| 'not_found';
|
|
||||||
|
|
||||||
export class MatrixRuntimeProviderError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly code: MatrixRuntimeProviderErrorCode,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = MatrixRuntimeProviderError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MatrixReadOperation =
|
|
||||||
| 'runtime.health'
|
|
||||||
| 'session.list'
|
|
||||||
| 'session.tree'
|
|
||||||
| 'session.stream'
|
|
||||||
| 'session.attach';
|
|
||||||
|
|
||||||
export interface MatrixReadAuthority {
|
|
||||||
canRead(input: {
|
|
||||||
operation: MatrixReadOperation;
|
|
||||||
scope: RuntimeScope;
|
|
||||||
sessionId?: string;
|
|
||||||
}): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatrixWriteAuthority {
|
|
||||||
canWrite(input: {
|
|
||||||
operation: 'session.send' | 'session.terminate';
|
|
||||||
sessionId: string;
|
|
||||||
scope: RuntimeScope;
|
|
||||||
approvalRef?: string;
|
|
||||||
}): Promise<boolean>;
|
|
||||||
assertAuthorized(input: {
|
|
||||||
operation: 'session.send' | 'session.terminate';
|
|
||||||
sessionId: string;
|
|
||||||
scope: RuntimeScope;
|
|
||||||
approvalRef?: string;
|
|
||||||
}): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatrixNativeRuntimeProviderOptions {
|
|
||||||
transport: MatrixRuntimeTransport;
|
|
||||||
readAuthority?: MatrixReadAuthority;
|
|
||||||
writeAuthority?: MatrixWriteAuthority;
|
|
||||||
attachmentIdFactory?: () => string;
|
|
||||||
now?: () => Date;
|
|
||||||
attachmentTtlMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Attachment {
|
|
||||||
sessionId: string;
|
|
||||||
scope: RuntimeScope;
|
|
||||||
expiresAtMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class DenyMatrixReadAuthority implements MatrixReadAuthority {
|
|
||||||
async canRead(): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
|
|
||||||
async canWrite(): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async assertAuthorized(): Promise<void> {
|
|
||||||
throw new MatrixRuntimeProviderError(
|
|
||||||
'forbidden',
|
|
||||||
'Matrix runtime writes require orchestrator authority',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Native Matrix adapter behind the Mosaic runtime contract. It accepts only
|
|
||||||
* stable session IDs; room identifiers, Matrix event schemas, and credentials
|
|
||||||
* are deliberately confined to the concrete transport implementation.
|
|
||||||
*/
|
|
||||||
export class MatrixNativeRuntimeProvider implements AgentRuntimeProvider {
|
|
||||||
readonly id = MATRIX_PROVIDER_ID;
|
|
||||||
private readonly readAuthority: MatrixReadAuthority;
|
|
||||||
private readonly writeAuthority: MatrixWriteAuthority;
|
|
||||||
private readonly attachmentIdFactory: () => string;
|
|
||||||
private readonly now: () => Date;
|
|
||||||
private readonly attachmentTtlMs: number;
|
|
||||||
private readonly attachments = new Map<string, Attachment>();
|
|
||||||
|
|
||||||
constructor(private readonly options: MatrixNativeRuntimeProviderOptions) {
|
|
||||||
this.readAuthority = options.readAuthority ?? new DenyMatrixReadAuthority();
|
|
||||||
this.writeAuthority = options.writeAuthority ?? new DenyMatrixWriteAuthority();
|
|
||||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
|
||||||
this.now = options.now ?? (() => new Date());
|
|
||||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
|
||||||
return {
|
|
||||||
supported: [
|
|
||||||
'session.list',
|
|
||||||
'session.tree',
|
|
||||||
'session.stream',
|
|
||||||
'session.send',
|
|
||||||
'session.attach',
|
|
||||||
'session.terminate',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
|
||||||
await this.assertRead('runtime.health', undefined, scope);
|
|
||||||
return this.options.transport.health(scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
|
||||||
await this.assertRead('session.list', undefined, scope);
|
|
||||||
const sessions = await this.options.transport.listSessions(scope);
|
|
||||||
const visible = await Promise.all(
|
|
||||||
sessions.map((session) =>
|
|
||||||
this.readAuthority.canRead({ operation: 'session.list', sessionId: session.id, scope }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return sessions
|
|
||||||
.filter((_session, index) => visible[index] === true)
|
|
||||||
.map((session) => this.runtimeSession(session));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
|
||||||
await this.assertRead('session.tree', undefined, scope);
|
|
||||||
const sessions = await this.options.transport.listSessions(scope);
|
|
||||||
const visible = await Promise.all(
|
|
||||||
sessions.map((session) =>
|
|
||||||
this.readAuthority.canRead({ operation: 'session.tree', sessionId: session.id, scope }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const runtimeSessions = sessions
|
|
||||||
.filter((_session, index) => visible[index] === true)
|
|
||||||
.map((session) => this.runtimeSession(session));
|
|
||||||
const nodes = new Map<string, RuntimeSessionTree>(
|
|
||||||
runtimeSessions.map((session) => [session.id, { session, children: [] }]),
|
|
||||||
);
|
|
||||||
const roots: RuntimeSessionTree[] = [];
|
|
||||||
for (const session of runtimeSessions) {
|
|
||||||
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.assertRead('session.stream', sessionId, scope);
|
|
||||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
|
||||||
await this.assertRead('session.stream', session.id, scope);
|
|
||||||
yield* this.options.transport.stream(session.id, cursor, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(
|
|
||||||
sessionId: string,
|
|
||||||
message: RuntimeMessage,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!message.content.trim()) {
|
|
||||||
throw new MatrixRuntimeProviderError(
|
|
||||||
'invalid_request',
|
|
||||||
'Matrix runtime message content is required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.assertWritePermitted('session.send', sessionId, scope);
|
|
||||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
|
||||||
await this.assertWriteAuthorized('session.send', session.id, scope);
|
|
||||||
await this.options.transport.send(session.id, message, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
async attach(
|
|
||||||
sessionId: string,
|
|
||||||
mode: RuntimeAttachMode,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<RuntimeAttachHandle> {
|
|
||||||
if (mode !== 'read') {
|
|
||||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix control attach is not permitted');
|
|
||||||
}
|
|
||||||
await this.assertRead('session.attach', sessionId, scope);
|
|
||||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
|
||||||
await this.assertRead('session.attach', session.id, scope);
|
|
||||||
const nowMs = this.now().getTime();
|
|
||||||
this.pruneExpired(nowMs);
|
|
||||||
const attachmentId = this.attachmentIdFactory();
|
|
||||||
const expiresAtMs = nowMs + this.attachmentTtlMs;
|
|
||||||
this.attachments.set(attachmentId, {
|
|
||||||
sessionId: session.id,
|
|
||||||
scope: snapshotScope(scope),
|
|
||||||
expiresAtMs,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
attachmentId,
|
|
||||||
sessionId: session.id,
|
|
||||||
mode,
|
|
||||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
const attachment = this.attachments.get(attachmentId);
|
|
||||||
if (!attachment)
|
|
||||||
throw new MatrixRuntimeProviderError('not_found', 'Matrix attachment is not active');
|
|
||||||
if (this.now().getTime() >= attachment.expiresAtMs) {
|
|
||||||
this.attachments.delete(attachmentId);
|
|
||||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment has expired');
|
|
||||||
}
|
|
||||||
if (!sameScope(attachment.scope, scope)) {
|
|
||||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment scope does not match');
|
|
||||||
}
|
|
||||||
this.attachments.delete(attachmentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
if (!approvalRef.trim()) {
|
|
||||||
throw new MatrixRuntimeProviderError(
|
|
||||||
'invalid_request',
|
|
||||||
'Matrix termination approval is required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef);
|
|
||||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
|
||||||
await this.assertWriteAuthorized('session.terminate', session.id, scope, approvalRef);
|
|
||||||
await this.options.transport.terminate(session.id, approvalRef, scope);
|
|
||||||
}
|
|
||||||
|
|
||||||
private runtimeSession(session: MatrixRuntimeSession): RuntimeSession {
|
|
||||||
return { ...session, providerId: this.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertRead(
|
|
||||||
operation: MatrixReadOperation,
|
|
||||||
sessionId: string | undefined,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<void> {
|
|
||||||
const allowed = await this.readAuthority.canRead({
|
|
||||||
operation,
|
|
||||||
scope,
|
|
||||||
...(sessionId ? { sessionId } : {}),
|
|
||||||
});
|
|
||||||
if (!allowed)
|
|
||||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime read is not authorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertWritePermitted(
|
|
||||||
operation: 'session.send' | 'session.terminate',
|
|
||||||
sessionId: string,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
approvalRef?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
const allowed = await this.writeAuthority.canWrite({
|
|
||||||
operation,
|
|
||||||
sessionId,
|
|
||||||
scope,
|
|
||||||
...(approvalRef ? { approvalRef } : {}),
|
|
||||||
});
|
|
||||||
if (!allowed)
|
|
||||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime write is not authorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertWriteAuthorized(
|
|
||||||
operation: 'session.send' | 'session.terminate',
|
|
||||||
sessionId: string,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
approvalRef?: string,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.writeAuthority.assertAuthorized({
|
|
||||||
operation,
|
|
||||||
sessionId,
|
|
||||||
scope,
|
|
||||||
...(approvalRef ? { approvalRef } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private pruneExpired(nowMs: number): void {
|
|
||||||
for (const [id, attachment] of this.attachments) {
|
|
||||||
if (attachment.expiresAtMs <= nowMs) this.attachments.delete(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotScope(scope: RuntimeScope): RuntimeScope {
|
|
||||||
return Object.freeze({ ...scope });
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameScope(left: RuntimeScope, right: RuntimeScope): boolean {
|
|
||||||
return (
|
|
||||||
left.actorId === right.actorId &&
|
|
||||||
left.tenantId === right.tenantId &&
|
|
||||||
left.channelId === right.channelId &&
|
|
||||||
left.correlationId === right.correlationId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { AgentRuntimeProvider, RuntimeScope } from '@mosaicstack/types';
|
|
||||||
import {
|
|
||||||
MatrixNativeRuntimeProvider,
|
|
||||||
type MatrixRuntimeTransport,
|
|
||||||
} from './matrix-native-runtime-provider.js';
|
|
||||||
import {
|
|
||||||
TmuxFleetRuntimeProvider,
|
|
||||||
type FleetRuntimeTransport,
|
|
||||||
} from './tmux-fleet-runtime-provider.js';
|
|
||||||
|
|
||||||
const scope: RuntimeScope = {
|
|
||||||
actorId: 'operator-1',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'corr-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ProviderFixture {
|
|
||||||
name: string;
|
|
||||||
provider: AgentRuntimeProvider;
|
|
||||||
providerId: string;
|
|
||||||
verifySession: unknown;
|
|
||||||
send: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fixtures(): ProviderFixture[] {
|
|
||||||
const fleetTransport: FleetRuntimeTransport = {
|
|
||||||
verifySession: vi.fn(async () => ({
|
|
||||||
id: 'session-1',
|
|
||||||
runtimeId: 'native-1',
|
|
||||||
socketName: 'fleet',
|
|
||||||
})),
|
|
||||||
listSessions: vi.fn(async () => [
|
|
||||||
{ id: 'session-1', runtimeId: 'native-1', socketName: 'fleet' },
|
|
||||||
]),
|
|
||||||
sendMessage: vi.fn(async () => undefined),
|
|
||||||
terminate: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
const fleet = new TmuxFleetRuntimeProvider({
|
|
||||||
transport: fleetTransport,
|
|
||||||
readAuthority: { canRead: vi.fn(async () => true) },
|
|
||||||
writeAuthority: {
|
|
||||||
canWrite: vi.fn(async () => true),
|
|
||||||
assertAuthorized: vi.fn(async () => undefined),
|
|
||||||
},
|
|
||||||
attachmentIdFactory: () => 'attachment-1',
|
|
||||||
now: () => new Date('2026-07-13T00:00:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const matrixTransport: MatrixRuntimeTransport = {
|
|
||||||
health: vi.fn(async () => ({
|
|
||||||
status: 'healthy' as const,
|
|
||||||
checkedAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
})),
|
|
||||||
verifySession: vi.fn(async () => ({
|
|
||||||
id: 'session-1',
|
|
||||||
runtimeId: 'native-1',
|
|
||||||
state: 'active' as const,
|
|
||||||
createdAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
})),
|
|
||||||
listSessions: vi.fn(async () => [
|
|
||||||
{
|
|
||||||
id: 'session-1',
|
|
||||||
runtimeId: 'native-1',
|
|
||||||
state: 'active' as const,
|
|
||||||
createdAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
stream: async function* () {},
|
|
||||||
send: vi.fn(async () => undefined),
|
|
||||||
terminate: vi.fn(async () => undefined),
|
|
||||||
};
|
|
||||||
const matrix = new MatrixNativeRuntimeProvider({
|
|
||||||
transport: matrixTransport,
|
|
||||||
readAuthority: { canRead: vi.fn(async () => true) },
|
|
||||||
writeAuthority: {
|
|
||||||
canWrite: vi.fn(async () => true),
|
|
||||||
assertAuthorized: vi.fn(async () => undefined),
|
|
||||||
},
|
|
||||||
attachmentIdFactory: () => 'attachment-1',
|
|
||||||
now: () => new Date('2026-07-13T00:00:00.000Z'),
|
|
||||||
});
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: 'tmux/fleet',
|
|
||||||
provider: fleet,
|
|
||||||
providerId: 'fleet.tmux',
|
|
||||||
verifySession: fleetTransport.verifySession,
|
|
||||||
send: fleetTransport.sendMessage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Matrix/native',
|
|
||||||
provider: matrix,
|
|
||||||
providerId: 'runtime.matrix',
|
|
||||||
verifySession: matrixTransport.verifySession,
|
|
||||||
send: matrixTransport.send,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shared contract tests for the migration-safe provider intersection. */
|
|
||||||
describe('tmux/fleet and Matrix/native provider parity', (): void => {
|
|
||||||
it.each(fixtures())(
|
|
||||||
'%s exposes the shared runtime operations',
|
|
||||||
async (fixture): Promise<void> => {
|
|
||||||
await expect(fixture.provider.capabilities(scope)).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
supported: expect.arrayContaining([
|
|
||||||
'session.list',
|
|
||||||
'session.tree',
|
|
||||||
'session.send',
|
|
||||||
'session.attach',
|
|
||||||
'session.terminate',
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(fixture.provider.listSessions(scope)).resolves.toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
id: 'session-1',
|
|
||||||
providerId: fixture.providerId,
|
|
||||||
runtimeId: 'native-1',
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(fixtures())(
|
|
||||||
'%s rejects empty messages before touching its transport',
|
|
||||||
async (fixture): Promise<void> => {
|
|
||||||
await expect(
|
|
||||||
fixture.provider.sendMessage(
|
|
||||||
'session-1',
|
|
||||||
{ content: '', idempotencyKey: 'message-1' },
|
|
||||||
scope,
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({ code: 'invalid_request' });
|
|
||||||
expect(fixture.verifySession).not.toHaveBeenCalled();
|
|
||||||
expect(fixture.send).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(fixtures())(
|
|
||||||
'%s rejects an empty termination approval before touching its transport',
|
|
||||||
async (fixture): Promise<void> => {
|
|
||||||
await expect(fixture.provider.terminate('session-1', '', scope)).rejects.toMatchObject({
|
|
||||||
code: 'invalid_request',
|
|
||||||
});
|
|
||||||
expect(fixture.verifySession).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(fixtures())(
|
|
||||||
'%s creates a read-only handle bound to immutable scope',
|
|
||||||
async (fixture): Promise<void> => {
|
|
||||||
await expect(fixture.provider.attach('session-1', 'control', scope)).rejects.toMatchObject({
|
|
||||||
code: 'forbidden',
|
|
||||||
});
|
|
||||||
expect(fixture.verifySession).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await expect(fixture.provider.attach('session-1', 'read', scope)).resolves.toEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
attachmentId: 'attachment-1',
|
|
||||||
sessionId: 'session-1',
|
|
||||||
mode: 'read',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
fixture.provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }),
|
|
||||||
).rejects.toMatchObject({ code: 'forbidden' });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
DurableSessionCoordinator,
|
DurableSessionCoordinator,
|
||||||
InMemoryDurableSessionStore,
|
InMemoryDurableSessionStore,
|
||||||
type DurableSessionIdentity,
|
type DurableSessionIdentity,
|
||||||
} from './durable-session.js';
|
} from './tess-durable-session.js';
|
||||||
|
|
||||||
const IDENTITY: DurableSessionIdentity = {
|
const IDENTITY: DurableSessionIdentity = {
|
||||||
agentName: 'Nova',
|
agentName: 'Nova',
|
||||||
@@ -102,7 +102,7 @@ export interface DurableSessionStore {
|
|||||||
|
|
||||||
export class DurableSessionNotFoundError extends Error {
|
export class DurableSessionNotFoundError extends Error {
|
||||||
constructor(sessionId: string) {
|
constructor(sessionId: string) {
|
||||||
super(`Durable session not found: ${sessionId}`);
|
super(`Durable Tess session not found: ${sessionId}`);
|
||||||
this.name = 'DurableSessionNotFoundError';
|
this.name = 'DurableSessionNotFoundError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,11 +212,11 @@ export class DurableSessionCoordinator {
|
|||||||
|
|
||||||
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
||||||
const handoff = await this.store.findHandoff(handoffId);
|
const handoff = await this.store.findHandoff(handoffId);
|
||||||
if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`);
|
if (!handoff) throw new Error(`Durable Tess handoff not found: ${handoffId}`);
|
||||||
const snapshot = await this.snapshot(handoff.sessionId);
|
const snapshot = await this.snapshot(handoff.sessionId);
|
||||||
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
||||||
if (!checkpoint) {
|
if (!checkpoint) {
|
||||||
throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
||||||
}
|
}
|
||||||
return { identity: snapshot.identity, checkpoint, handoff };
|
return { identity: snapshot.identity, checkpoint, handoff };
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = this.sessions.get(identity.sessionId);
|
const existing = this.sessions.get(identity.sessionId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameEnrollmentScope(existing.identity, identity)) {
|
if (!sameEnrollmentScope(existing.identity, identity)) {
|
||||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
throw new Error(`Durable Tess session identity conflict: ${identity.sessionId}`);
|
||||||
}
|
}
|
||||||
existing.identity.providerId = identity.providerId;
|
existing.identity.providerId = identity.providerId;
|
||||||
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
||||||
@@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = state.inbox.get(input.idempotencyKey);
|
const existing = state.inbox.get(input.idempotencyKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameInbox(existing, input)) {
|
if (!sameInbox(existing, input)) {
|
||||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable Tess inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: existing.status };
|
return { accepted: false, status: existing.status };
|
||||||
}
|
}
|
||||||
@@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const existing = state.outbox.get(input.idempotencyKey);
|
const existing = state.outbox.get(input.idempotencyKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (!sameOutbox(existing, input)) {
|
if (!sameOutbox(existing, input)) {
|
||||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||||
}
|
}
|
||||||
return { accepted: false, status: existing.status };
|
return { accepted: false, status: existing.status };
|
||||||
}
|
}
|
||||||
@@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
const checkpoints = this.require(input.sessionId).checkpoints;
|
const checkpoints = this.require(input.sessionId).checkpoints;
|
||||||
const existing = checkpoints.get(input.checkpointId);
|
const existing = checkpoints.get(input.checkpointId);
|
||||||
if (existing && !sameCheckpoint(existing, input)) {
|
if (existing && !sameCheckpoint(existing, input)) {
|
||||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
||||||
}
|
}
|
||||||
@@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||||
const state = this.require(input.sessionId);
|
const state = this.require(input.sessionId);
|
||||||
if (!state.checkpoints.has(input.checkpointId)) {
|
if (!state.checkpoints.has(input.checkpointId)) {
|
||||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||||
}
|
}
|
||||||
const existing = state.handoffs.get(input.handoffId);
|
const existing = state.handoffs.get(input.handoffId);
|
||||||
if (existing && !sameHandoff(existing, input)) {
|
if (existing && !sameHandoff(existing, input)) {
|
||||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
|
||||||
}
|
}
|
||||||
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
||||||
}
|
}
|
||||||
@@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
|
|||||||
kind: string,
|
kind: string,
|
||||||
): T {
|
): T {
|
||||||
const entry = records.get(idempotencyKey);
|
const entry = records.get(idempotencyKey);
|
||||||
if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
|
if (!entry) throw new Error(`Durable Tess ${kind} entry not found: ${idempotencyKey}`);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,7 +202,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
expect(fleet.terminate).not.toHaveBeenCalled();
|
expect(fleet.terminate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unverified target before consulting orchestrator write authority', async (): Promise<void> => {
|
it('rejects an unverified target before consulting Mos write authority', async (): Promise<void> => {
|
||||||
const fleet = transport();
|
const fleet = transport();
|
||||||
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
||||||
throw new Error('target identity mismatch');
|
throw new Error('target identity mismatch');
|
||||||
@@ -220,20 +220,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the role-neutral interaction source label by default', async (): Promise<void> => {
|
it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise<void> => {
|
||||||
const fleet = transport();
|
|
||||||
const writeAuthority: FleetWriteAuthority = {
|
|
||||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
|
||||||
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
|
||||||
};
|
|
||||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
|
|
||||||
|
|
||||||
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
|
||||||
|
|
||||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise<void> => {
|
|
||||||
const fleet = transport();
|
const fleet = transport();
|
||||||
const writeAuthority: FleetWriteAuthority = {
|
const writeAuthority: FleetWriteAuthority = {
|
||||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||||
@@ -241,7 +228,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
};
|
};
|
||||||
const provider = new TmuxFleetRuntimeProvider({
|
const provider = new TmuxFleetRuntimeProvider({
|
||||||
transport: fleet,
|
transport: fleet,
|
||||||
sourceLabel: 'operator',
|
sourceLabel: 'tess',
|
||||||
writeAuthority,
|
writeAuthority,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,7 +246,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
|||||||
scope,
|
scope,
|
||||||
approvalRef: 'approval-1',
|
approvalRef: 'approval-1',
|
||||||
});
|
});
|
||||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
|
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess');
|
||||||
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -64,14 +64,14 @@ export interface FleetWriteAuthorization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The orchestrator is the only authority that may permit interaction-plane write/control requests to a
|
* Mos is the only authority that may permit Tess write/control requests to a
|
||||||
* fleet peer. Gateway records the request and denial/success around provider
|
* fleet peer. Gateway records the request and denial/success around provider
|
||||||
* invocation; the default authority prevents direct interaction-plane writes by design.
|
* invocation; the default authority prevents direct Tess writes by design.
|
||||||
*/
|
*/
|
||||||
export interface FleetWriteAuthority {
|
export interface FleetWriteAuthority {
|
||||||
/** Non-consuming preflight used before probing the fleet transport. */
|
/** Non-consuming preflight used before probing the fleet transport. */
|
||||||
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
||||||
/** Final exact-target authorization; may consume an orchestrator grant. */
|
/** Final exact-target authorization; may consume a Mos grant. */
|
||||||
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
|||||||
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
||||||
throw new FleetRuntimeProviderError(
|
throw new FleetRuntimeProviderError(
|
||||||
'forbidden',
|
'forbidden',
|
||||||
'Fleet writes require an explicit orchestrator authority decision',
|
'Fleet writes require an explicit Mos authority decision',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,7 +124,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
|||||||
/**
|
/**
|
||||||
* A capability-limited provider for rostered local fleet peers. It never
|
* A capability-limited provider for rostered local fleet peers. It never
|
||||||
* permits raw tmux socket/target selection, interactive control attach, or
|
* permits raw tmux socket/target selection, interactive control attach, or
|
||||||
* direct interaction-plane writes; all side effects pass through exact transport checks.
|
* direct Tess writes; all side effects pass through exact transport checks.
|
||||||
*/
|
*/
|
||||||
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
||||||
readonly id = FLEET_PROVIDER_ID;
|
readonly id = FLEET_PROVIDER_ID;
|
||||||
@@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
|||||||
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
||||||
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
||||||
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
||||||
this.sourceLabel = options.sourceLabel ?? 'interaction';
|
this.sourceLabel = options.sourceLabel ?? 'tess';
|
||||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
||||||
this.now = options.now ?? (() => new Date());
|
this.now = options.now ?? (() => new Date());
|
||||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
InMemoryInteractionCoordinationPort,
|
|
||||||
InteractionCoordinationClient,
|
|
||||||
type CoordinationScope,
|
|
||||||
type InteractionCoordinationAuthorityError,
|
|
||||||
type InteractionCoordinationPort,
|
|
||||||
} from '../index.js';
|
|
||||||
|
|
||||||
const scope: CoordinationScope = {
|
|
||||||
actorId: 'operator-1',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
correlationId: 'corr-1',
|
|
||||||
requesterAgentId: 'Nova',
|
|
||||||
};
|
|
||||||
|
|
||||||
function client(
|
|
||||||
port: InteractionCoordinationPort,
|
|
||||||
handoffIdFactory: () => string = (): string => 'handoff-1',
|
|
||||||
): InteractionCoordinationClient {
|
|
||||||
return new InteractionCoordinationClient(
|
|
||||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
|
|
||||||
port,
|
|
||||||
handoffIdFactory,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('InteractionCoordinationClient', (): void => {
|
|
||||||
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
|
|
||||||
const adapter = new InMemoryInteractionCoordinationPort();
|
|
||||||
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', 'Orchestrator accepted the request');
|
|
||||||
adapter.recordResult('handoff-1', 'completed', 'Merged by orchestrator');
|
|
||||||
|
|
||||||
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 orchestrator',
|
|
||||||
});
|
|
||||||
|
|
||||||
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 orchestrator work', async (): Promise<void> => {
|
|
||||||
const adapter = new InMemoryInteractionCoordinationPort();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
client(adapter).handoff(
|
|
||||||
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
|
|
||||||
{ ...scope, requesterAgentId: 'Untrusted' },
|
|
||||||
),
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'requester_forbidden',
|
|
||||||
} satisfies Partial<InteractionCoordinationAuthorityError>);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects self-delegation configuration before constructing a client', (): void => {
|
|
||||||
expect(
|
|
||||||
(): InteractionCoordinationClient =>
|
|
||||||
new InteractionCoordinationClient(
|
|
||||||
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
|
|
||||||
new InMemoryInteractionCoordinationPort(),
|
|
||||||
),
|
|
||||||
).toThrow('Interaction and orchestration identities must differ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects whitespace-equivalent self-delegation identities', (): void => {
|
|
||||||
expect(
|
|
||||||
(): InteractionCoordinationClient =>
|
|
||||||
new InteractionCoordinationClient(
|
|
||||||
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
|
|
||||||
new InMemoryInteractionCoordinationPort(),
|
|
||||||
),
|
|
||||||
).toThrow('Interaction and orchestration identities must differ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not expose another tenant handoff to observe or result', async (): Promise<void> => {
|
|
||||||
const adapter = new InMemoryInteractionCoordinationPort();
|
|
||||||
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 InMemoryInteractionCoordinationPort({ 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: InteractionCoordinationPort = {
|
|
||||||
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<InteractionCoordinationAuthorityError>);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
import {
|
|
||||||
type CoordinationObservation,
|
|
||||||
type CoordinationResult,
|
|
||||||
type CoordinationScope,
|
|
||||||
type InteractionCoordinationActivity,
|
|
||||||
type InteractionCoordinationPort,
|
|
||||||
type Handoff,
|
|
||||||
type HandoffReceipt,
|
|
||||||
type HandoffStatus,
|
|
||||||
} from './interaction-coordination.js';
|
|
||||||
|
|
||||||
const DEFAULT_HANDOFF_TTL_MS = 60 * 60 * 1_000;
|
|
||||||
const DEFAULT_MAX_HANDOFFS = 1_000;
|
|
||||||
|
|
||||||
interface StoredHandoff {
|
|
||||||
readonly handoff: Handoff;
|
|
||||||
status: HandoffStatus;
|
|
||||||
readonly activity: InteractionCoordinationActivity[];
|
|
||||||
readonly expiresAt: number;
|
|
||||||
result?: CoordinationResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InMemoryInteractionCoordinationPortOptions {
|
|
||||||
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 InteractionCoordinationPort without changing interaction-plane callers.
|
|
||||||
*/
|
|
||||||
export class InMemoryInteractionCoordinationPort implements InteractionCoordinationPort {
|
|
||||||
private readonly handoffs = new Map<string, StoredHandoff>();
|
|
||||||
private readonly now: () => Date;
|
|
||||||
private readonly handoffTtlMs: number;
|
|
||||||
private readonly maxHandoffs: number;
|
|
||||||
|
|
||||||
constructor(options: InMemoryInteractionCoordinationPortOptions = {}) {
|
|
||||||
this.now = options.now ?? (() => new Date());
|
|
||||||
this.handoffTtlMs = options.handoffTtlMs ?? DEFAULT_HANDOFF_TTL_MS;
|
|
||||||
this.maxHandoffs = options.maxHandoffs ?? DEFAULT_MAX_HANDOFFS;
|
|
||||||
}
|
|
||||||
|
|
||||||
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
|
|
||||||
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: HandoffStatus, 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 an orchestrator 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: Handoff, status: HandoffStatus): HandoffReceipt {
|
|
||||||
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 InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match');
|
|
||||||
}
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireHandoff(handoffId: string): StoredHandoff {
|
|
||||||
const stored = this.handoffs.get(handoffId);
|
|
||||||
if (stored === undefined) {
|
|
||||||
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
|
|
||||||
}
|
|
||||||
return stored;
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertSameHandoff(existing: Handoff, incoming: Handoff): 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 InMemoryInteractionCoordinationError(
|
|
||||||
'conflict',
|
|
||||||
'Handoff ID is already bound to different immutable input',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
|
|
||||||
|
|
||||||
export class InMemoryInteractionCoordinationError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly code: InMemoryInteractionCoordinationErrorCode,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = InMemoryInteractionCoordinationError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activity(
|
|
||||||
status: HandoffStatus,
|
|
||||||
summary: string,
|
|
||||||
now: () => Date,
|
|
||||||
): InteractionCoordinationActivity {
|
|
||||||
return { occurredAt: now().toISOString(), status, summary };
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
|
|
||||||
return { ...entry };
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotHandoff(handoff: Handoff): Handoff {
|
|
||||||
return Object.freeze({
|
|
||||||
handoffId: handoff.handoffId,
|
|
||||||
targetAgentId: handoff.targetAgentId,
|
|
||||||
request: Object.freeze({ ...handoff.request }),
|
|
||||||
scope: Object.freeze({ ...handoff.scope }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -2,26 +2,6 @@ export { createMission, loadMission, missionFilePath, saveMission } from './miss
|
|||||||
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.js';
|
||||||
export { runTask, resumeTask } from './runner.js';
|
export { runTask, resumeTask } from './runner.js';
|
||||||
export { getMissionStatus, getTaskStatus } from './status.js';
|
export { getMissionStatus, getTaskStatus } from './status.js';
|
||||||
export {
|
|
||||||
InMemoryInteractionCoordinationError,
|
|
||||||
InMemoryInteractionCoordinationPort,
|
|
||||||
} from './in-memory-interaction-coordination-port.js';
|
|
||||||
export {
|
|
||||||
InteractionCoordinationAuthorityError,
|
|
||||||
InteractionCoordinationClient,
|
|
||||||
} from './interaction-coordination.js';
|
|
||||||
export type {
|
|
||||||
CoordinationObservation,
|
|
||||||
CoordinationResult,
|
|
||||||
CoordinationScope,
|
|
||||||
InteractionCoordinationActivity,
|
|
||||||
InteractionCoordinationIdentity,
|
|
||||||
InteractionCoordinationPort,
|
|
||||||
Handoff,
|
|
||||||
HandoffReceipt,
|
|
||||||
HandoffRequest,
|
|
||||||
HandoffStatus,
|
|
||||||
} from './interaction-coordination.js';
|
|
||||||
export type {
|
export type {
|
||||||
CreateMissionOptions,
|
CreateMissionOptions,
|
||||||
Mission,
|
Mission,
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
export type HandoffStatus = '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 InteractionCoordinationIdentity {
|
|
||||||
readonly interactionAgentId: string;
|
|
||||||
readonly orchestrationAgentId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HandoffRequest {
|
|
||||||
readonly idempotencyKey: string;
|
|
||||||
readonly summary: string;
|
|
||||||
readonly context?: string;
|
|
||||||
readonly missionId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Handoff {
|
|
||||||
readonly handoffId: string;
|
|
||||||
readonly targetAgentId: string;
|
|
||||||
readonly request: HandoffRequest;
|
|
||||||
readonly scope: CoordinationScope;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HandoffReceipt {
|
|
||||||
readonly handoffId: string;
|
|
||||||
readonly targetAgentId: string;
|
|
||||||
readonly status: 'queued' | 'accepted';
|
|
||||||
readonly correlationId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InteractionCoordinationActivity {
|
|
||||||
readonly occurredAt: string;
|
|
||||||
readonly status: HandoffStatus;
|
|
||||||
readonly summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CoordinationObservation {
|
|
||||||
readonly handoffId: string;
|
|
||||||
readonly targetAgentId: string;
|
|
||||||
readonly status: HandoffStatus;
|
|
||||||
readonly correlationId: string;
|
|
||||||
readonly activity: readonly InteractionCoordinationActivity[];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 InteractionCoordinationPort {
|
|
||||||
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
|
||||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
|
||||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InteractionCoordinationAuthorityErrorCode =
|
|
||||||
| 'invalid_identity'
|
|
||||||
| 'requester_forbidden'
|
|
||||||
| 'target_drift'
|
|
||||||
| 'correlation_drift';
|
|
||||||
|
|
||||||
export class InteractionCoordinationAuthorityError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly code: InteractionCoordinationAuthorityErrorCode,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = InteractionCoordinationAuthorityError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enforces the interaction-to-orchestration authority boundary before a
|
|
||||||
* transport is reached. Identity names remain configuration data.
|
|
||||||
*/
|
|
||||||
export class InteractionCoordinationClient {
|
|
||||||
private readonly identity: InteractionCoordinationIdentity;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
identity: InteractionCoordinationIdentity,
|
|
||||||
private readonly port: InteractionCoordinationPort,
|
|
||||||
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
|
|
||||||
) {
|
|
||||||
this.identity = normalizeIdentity(identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
|
|
||||||
this.assertRequester(scope);
|
|
||||||
const handoff: Handoff = {
|
|
||||||
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 InteractionCoordinationAuthorityError(
|
|
||||||
'target_drift',
|
|
||||||
'Interaction coordination transport returned a mismatched handoff target',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (receipt.correlationId !== handoff.scope.correlationId) {
|
|
||||||
throw new InteractionCoordinationAuthorityError(
|
|
||||||
'correlation_drift',
|
|
||||||
'Interaction 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 InteractionCoordinationAuthorityError(
|
|
||||||
'requester_forbidden',
|
|
||||||
'Requester is not the configured interaction agent',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private assertObservation(
|
|
||||||
observation: CoordinationObservation,
|
|
||||||
scope: CoordinationScope,
|
|
||||||
): CoordinationObservation {
|
|
||||||
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
|
|
||||||
throw new InteractionCoordinationAuthorityError(
|
|
||||||
'target_drift',
|
|
||||||
'Interaction coordination transport returned an unexpected observation target',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (observation.correlationId !== scope.correlationId) {
|
|
||||||
throw new InteractionCoordinationAuthorityError(
|
|
||||||
'correlation_drift',
|
|
||||||
'Interaction 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 InteractionCoordinationAuthorityError(
|
|
||||||
'target_drift',
|
|
||||||
'Interaction coordination transport returned an unexpected result target',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (result.correlationId !== scope.correlationId) {
|
|
||||||
throw new InteractionCoordinationAuthorityError(
|
|
||||||
'correlation_drift',
|
|
||||||
'Interaction coordination transport returned a mismatched result correlation ID',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIdentity(
|
|
||||||
identity: InteractionCoordinationIdentity,
|
|
||||||
): InteractionCoordinationIdentity {
|
|
||||||
const interactionAgentId = identity.interactionAgentId.trim();
|
|
||||||
const orchestrationAgentId = identity.orchestrationAgentId.trim();
|
|
||||||
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
|
|
||||||
throw new InteractionCoordinationAuthorityError(
|
|
||||||
'invalid_identity',
|
|
||||||
'Interaction and orchestration identities are required',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (interactionAgentId === orchestrationAgentId) {
|
|
||||||
throw new InteractionCoordinationAuthorityError(
|
|
||||||
'invalid_identity',
|
|
||||||
'Interaction and orchestration identities must differ',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Object.freeze({ interactionAgentId, orchestrationAgentId });
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotRequest(request: HandoffRequest): HandoffRequest {
|
|
||||||
return Object.freeze({ ...request });
|
|
||||||
}
|
|
||||||
|
|
||||||
function snapshotScope(scope: CoordinationScope): CoordinationScope {
|
|
||||||
return Object.freeze({ ...scope });
|
|
||||||
}
|
|
||||||
@@ -9,8 +9,7 @@ export type RuntimeAuditOperation =
|
|||||||
| 'session.attach'
|
| 'session.attach'
|
||||||
| 'session.terminate'
|
| 'session.terminate'
|
||||||
| 'runtime.capabilities'
|
| 'runtime.capabilities'
|
||||||
| 'runtime.health'
|
| 'runtime.health';
|
||||||
| 'runtime.transitional-capabilities';
|
|
||||||
|
|
||||||
export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error';
|
export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error';
|
||||||
|
|||||||
@@ -274,20 +274,6 @@ describe('KeywordAdapter', () => {
|
|||||||
expect(results).toHaveLength(1);
|
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 () => {
|
it('should return empty for empty query', async () => {
|
||||||
const results = await adapter.searchInsights('u1', ' ');
|
const results = await adapter.searchInsights('u1', ' ');
|
||||||
expect(results).toHaveLength(0);
|
expect(results).toHaveLength(0);
|
||||||
|
|||||||
@@ -132,23 +132,19 @@ export class KeywordAdapter implements MemoryAdapter {
|
|||||||
opts?: { limit?: number; embedding?: number[] },
|
opts?: { limit?: number; embedding?: number[] },
|
||||||
): Promise<InsightSearchResult[]> {
|
): Promise<InsightSearchResult[]> {
|
||||||
const limit = opts?.limit ?? 10;
|
const limit = opts?.limit ?? 10;
|
||||||
const normalizedQuery = query.trim();
|
const words = query
|
||||||
const matchAll = normalizedQuery === '*';
|
.toLowerCase()
|
||||||
const words = matchAll
|
.split(/\s+/)
|
||||||
? []
|
.filter((w) => w.length > 0);
|
||||||
: normalizedQuery
|
|
||||||
.toLowerCase()
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((word) => word.length > 0);
|
|
||||||
|
|
||||||
if (words.length === 0 && !matchAll) return [];
|
if (words.length === 0) return [];
|
||||||
|
|
||||||
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId });
|
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId });
|
||||||
|
|
||||||
const scored: InsightSearchResult[] = [];
|
const scored: InsightSearchResult[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const content = row.content.toLowerCase();
|
const content = row.content.toLowerCase();
|
||||||
let score = matchAll ? 1 : 0;
|
let score = 0;
|
||||||
for (const word of words) {
|
for (const word of words) {
|
||||||
if (content.includes(word)) score++;
|
if (content.includes(word)) score++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,6 @@ export type {
|
|||||||
InsightSearchResult,
|
InsightSearchResult,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
export { createMemoryAdapter, registerMemoryAdapter } from './factory.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 { PgVectorAdapter } from './adapters/pgvector.js';
|
||||||
export { KeywordAdapter } from './adapters/keyword.js';
|
export { KeywordAdapter } from './adapters/keyword.js';
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
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,10 +49,6 @@ export interface MemoryAdapter {
|
|||||||
// Insights
|
// Insights
|
||||||
storeInsight(insight: NewInsight): Promise<Insight>;
|
storeInsight(insight: NewInsight): Promise<Insight>;
|
||||||
getInsight(id: string): Promise<Insight | null>;
|
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(
|
searchInsights(
|
||||||
userId: string,
|
userId: string,
|
||||||
query: string,
|
query: string,
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
The **operator-interaction** role is the authorized human interaction plane for
|
The **operator-interaction** role is the authorized human interaction plane for
|
||||||
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
Mosaic. It presents runtime and fleet state, mediates approved actions, and
|
||||||
hands coding or general orchestration work to the orchestrator.
|
hands coding or general orchestration work to Mos.
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
- It does not claim orchestrator-owned coding or general orchestration work.
|
- It does not claim Mos-owned coding or general orchestration work.
|
||||||
- It exposes only the configured, observable tool policy.
|
- It exposes only the configured, observable tool policy.
|
||||||
- It does not receive or surface credentials in its effective policy.
|
- It does not receive or surface credentials in its effective policy.
|
||||||
|
|||||||
@@ -75,14 +75,6 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"pattern": "^[A-Za-z0-9_.-]+$"
|
"pattern": "^[A-Za-z0-9_.-]+$"
|
||||||
},
|
},
|
||||||
"alias": {
|
|
||||||
"description": "Optional operator-defined display name for the agent.",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"provider": {
|
|
||||||
"description": "Optional agent runtime provider identifier such as openai-codex.",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -189,36 +189,6 @@ describe('fleet roster parsing', () => {
|
|||||||
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
expect(getRosterAgent(roster, 'canary-pi').runtime).toBe('pi');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts optional agent alias and provider metadata without requiring them', async () => {
|
|
||||||
cleanup = await tempDir();
|
|
||||||
const rosterPath = join(cleanup, 'roster.yaml');
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'agents:',
|
|
||||||
' - name: interaction',
|
|
||||||
' alias: Friday',
|
|
||||||
' provider: openai-codex',
|
|
||||||
' runtime: pi',
|
|
||||||
' - name: worker0',
|
|
||||||
' runtime: codex',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
|
||||||
|
|
||||||
expect(getRosterAgent(roster, 'interaction')).toMatchObject({
|
|
||||||
name: 'interaction',
|
|
||||||
alias: 'Friday',
|
|
||||||
provider: 'openai-codex',
|
|
||||||
});
|
|
||||||
expect(getRosterAgent(roster, 'worker0')).toMatchObject({ name: 'worker0' });
|
|
||||||
expect(getRosterAgent(roster, 'worker0').alias).toBeUndefined();
|
|
||||||
expect(getRosterAgent(roster, 'worker0').provider).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
it('socketArgs: named socket → -L <name>; empty → no -L (default socket)', () => {
|
||||||
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
|
||||||
expect(socketArgs('')).toEqual([]);
|
expect(socketArgs('')).toEqual([]);
|
||||||
@@ -1902,61 +1872,7 @@ describe('fleet ps — tenant and host', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fleet ps — aliases and JSON output shape (FR-6)', () => {
|
describe('fleet ps — JSON output shape (FR-6)', () => {
|
||||||
it('shows an agent alias in table output and falls back to name when absent', async () => {
|
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
|
||||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
|
||||||
await writeFile(
|
|
||||||
rosterPath,
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'transport: tmux',
|
|
||||||
'agents:',
|
|
||||||
' - name: interaction',
|
|
||||||
' alias: Friday',
|
|
||||||
' runtime: pi',
|
|
||||||
' - name: worker0',
|
|
||||||
' runtime: codex',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const runner: CommandRunner = async (command, args) => {
|
|
||||||
const fullArgs = [command, ...args].join(' ');
|
|
||||||
if (fullArgs.includes('list-sessions')) {
|
|
||||||
return { stdout: 'interaction\nworker0\n', stderr: '', exitCode: 0 };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
stdout: fullArgs.includes('systemctl')
|
|
||||||
? 'ActiveState=active\nSubState=running\nUnitFileState=enabled\n'
|
|
||||||
: '12345 pi 0 0 0 0\n',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
const origLog = console.log;
|
|
||||||
console.log = (msg: string) => {
|
|
||||||
lines.push(msg);
|
|
||||||
};
|
|
||||||
|
|
||||||
const program = new Command();
|
|
||||||
program.exitOverride();
|
|
||||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'ps']);
|
|
||||||
} finally {
|
|
||||||
console.log = origLog;
|
|
||||||
await rm(home, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = lines.join('\n');
|
|
||||||
expect(output).toContain('Friday');
|
|
||||||
expect(output).toContain('worker0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('produces --json records including tenant_id and host for each agent', async () => {
|
it('produces --json records including tenant_id and host for each agent', async () => {
|
||||||
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
|
||||||
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
const rosterPath = join(home, 'fleet', 'roster.yaml');
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ interface RawFleetRoster {
|
|||||||
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
|
||||||
agents?: Array<{
|
agents?: Array<{
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
alias?: unknown;
|
|
||||||
provider?: unknown;
|
|
||||||
runtime?: unknown;
|
runtime?: unknown;
|
||||||
class?: unknown;
|
class?: unknown;
|
||||||
working_directory?: unknown;
|
working_directory?: unknown;
|
||||||
@@ -104,8 +102,6 @@ interface RawFleetRoster {
|
|||||||
|
|
||||||
export interface FleetAgent {
|
export interface FleetAgent {
|
||||||
name: string;
|
name: string;
|
||||||
alias?: string;
|
|
||||||
provider?: string;
|
|
||||||
runtime: string;
|
runtime: string;
|
||||||
className: string;
|
className: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
@@ -1078,7 +1074,6 @@ export interface HeartbeatInfo {
|
|||||||
|
|
||||||
export interface AgentPsRow {
|
export interface AgentPsRow {
|
||||||
name: string;
|
name: string;
|
||||||
alias?: string;
|
|
||||||
tenant_id: string;
|
tenant_id: string;
|
||||||
host: string;
|
host: string;
|
||||||
runtime: string;
|
runtime: string;
|
||||||
@@ -1760,7 +1755,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
rows.push({
|
rows.push({
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
alias: agent.alias,
|
|
||||||
tenant_id,
|
tenant_id,
|
||||||
host,
|
host,
|
||||||
runtime: agent.runtime,
|
runtime: agent.runtime,
|
||||||
@@ -1894,7 +1888,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
[
|
[
|
||||||
(row.alias ?? row.name).padEnd(18),
|
row.name.padEnd(18),
|
||||||
row.tenant_id.padEnd(12),
|
row.tenant_id.padEnd(12),
|
||||||
row.host.padEnd(12),
|
row.host.padEnd(12),
|
||||||
row.runtime.padEnd(10),
|
row.runtime.padEnd(10),
|
||||||
@@ -2500,8 +2494,6 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
assertObject(raw, 'Fleet roster agent');
|
assertObject(raw, 'Fleet roster agent');
|
||||||
assertKnownKeys(raw, 'Fleet roster agent', [
|
assertKnownKeys(raw, 'Fleet roster agent', [
|
||||||
'name',
|
'name',
|
||||||
'alias',
|
|
||||||
'provider',
|
|
||||||
'runtime',
|
'runtime',
|
||||||
'class',
|
'class',
|
||||||
'working_directory',
|
'working_directory',
|
||||||
@@ -2533,8 +2525,6 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
|
|
||||||
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
|
|
||||||
runtime,
|
runtime,
|
||||||
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
|
||||||
workingDirectory: optionalString(
|
workingDirectory: optionalString(
|
||||||
@@ -2837,12 +2827,6 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
|
|||||||
runtime: agent.runtime,
|
runtime: agent.runtime,
|
||||||
class: agent.className,
|
class: agent.className,
|
||||||
};
|
};
|
||||||
if (agent.alias !== undefined) {
|
|
||||||
raw['alias'] = agent.alias;
|
|
||||||
}
|
|
||||||
if (agent.provider !== undefined) {
|
|
||||||
raw['provider'] = agent.provider;
|
|
||||||
}
|
|
||||||
if (agent.workingDirectory !== undefined) {
|
if (agent.workingDirectory !== undefined) {
|
||||||
raw['working_directory'] = agent.workingDirectory;
|
raw['working_directory'] = agent.workingDirectory;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ function readFleetToolPolicyBlock(policy: string | undefined): string {
|
|||||||
'',
|
'',
|
||||||
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
'Permitted: authorized conversation, status, retrieval, and safe diagnostics.',
|
||||||
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
'Denied by default: coding/general orchestration claims, direct fleet control, destructive actions, and credential access.',
|
||||||
'Delegate orchestrator-owned work through the authorized handoff boundary.',
|
'Delegate Mos-owned work through the authorized handoff boundary.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
import type { RuntimeScope } from '@mosaicstack/types';
|
|
||||||
import {
|
|
||||||
MatrixNativeRuntimeTransport,
|
|
||||||
type MatrixFetchLike,
|
|
||||||
} from './matrix-native-runtime-transport.js';
|
|
||||||
|
|
||||||
const scope: RuntimeScope = {
|
|
||||||
actorId: 'operator-1',
|
|
||||||
tenantId: 'tenant-a',
|
|
||||||
channelId: 'cli',
|
|
||||||
correlationId: 'corr-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
const bindings = [
|
|
||||||
{
|
|
||||||
id: 'native-1',
|
|
||||||
runtimeId: '@native-worker:example.test',
|
|
||||||
roomId: '!room:example.test',
|
|
||||||
remoteUserId: '@native-worker:example.test',
|
|
||||||
state: 'active' as const,
|
|
||||||
createdAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function response(
|
|
||||||
status: number,
|
|
||||||
body: unknown = {},
|
|
||||||
): ReturnType<MatrixFetchLike> extends Promise<infer T> ? T : never {
|
|
||||||
return {
|
|
||||||
ok: status >= 200 && status < 300,
|
|
||||||
status,
|
|
||||||
json: async () => body,
|
|
||||||
text: async () => JSON.stringify(body),
|
|
||||||
} as never;
|
|
||||||
}
|
|
||||||
|
|
||||||
function transport(fetchImpl: MatrixFetchLike): MatrixNativeRuntimeTransport {
|
|
||||||
return new MatrixNativeRuntimeTransport({
|
|
||||||
homeserverUrl: 'https://matrix.example.test/base',
|
|
||||||
accessToken: 'test-token',
|
|
||||||
userId: '@mosaic:example.test',
|
|
||||||
sessions: bindings,
|
|
||||||
fetchImpl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('MatrixNativeRuntimeTransport', (): void => {
|
|
||||||
it('uses only configured session-to-room bindings and idempotency-derived Matrix transactions', async (): Promise<void> => {
|
|
||||||
const fetchImpl = vi
|
|
||||||
.fn<MatrixFetchLike>()
|
|
||||||
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
|
|
||||||
.mockResolvedValueOnce(response(200, { event_id: '$sent' }));
|
|
||||||
const matrix = transport(fetchImpl);
|
|
||||||
|
|
||||||
await matrix.send('native-1', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
|
||||||
|
|
||||||
expect(fetchImpl).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'https://matrix.example.test/base/_matrix/client/v3/account/whoami',
|
|
||||||
expect.objectContaining({
|
|
||||||
headers: expect.objectContaining({ Authorization: 'Bearer test-token' }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [url, init] = fetchImpl.mock.calls[1]!;
|
|
||||||
expect(url).toMatch(
|
|
||||||
/^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/m\.room\.message\/mosaic-send-[A-Za-z0-9_-]+$/,
|
|
||||||
);
|
|
||||||
expect(init?.method).toBe('PUT');
|
|
||||||
expect(JSON.parse(init?.body ?? '')).toEqual({
|
|
||||||
msgtype: 'm.text',
|
|
||||||
body: 'hello',
|
|
||||||
'mosaic.runtime.v1': {
|
|
||||||
session_id: 'native-1',
|
|
||||||
runtime_id: '@native-worker:example.test',
|
|
||||||
actor_id: 'operator-1',
|
|
||||||
tenant_id: 'tenant-a',
|
|
||||||
channel_id: 'cli',
|
|
||||||
correlation_id: 'corr-1',
|
|
||||||
idempotency_key: 'message-1',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends a termination only to the bound room with the exact approval reference', async (): Promise<void> => {
|
|
||||||
const fetchImpl = vi
|
|
||||||
.fn<MatrixFetchLike>()
|
|
||||||
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
|
|
||||||
.mockResolvedValueOnce(response(200, { event_id: '$terminated' }));
|
|
||||||
const matrix = transport(fetchImpl);
|
|
||||||
|
|
||||||
await matrix.terminate('native-1', 'approval-1', scope);
|
|
||||||
|
|
||||||
const [url, init] = fetchImpl.mock.calls[1]!;
|
|
||||||
expect(url).toMatch(
|
|
||||||
/^https:\/\/matrix\.example\.test\/base\/_matrix\/client\/v3\/rooms\/!room%3Aexample\.test\/send\/mosaic\.runtime\.terminate\/mosaic-terminate-[A-Za-z0-9_-]+$/,
|
|
||||||
);
|
|
||||||
expect(JSON.parse(init?.body ?? '')).toEqual({
|
|
||||||
session_id: 'native-1',
|
|
||||||
runtime_id: '@native-worker:example.test',
|
|
||||||
actor_id: 'operator-1',
|
|
||||||
tenant_id: 'tenant-a',
|
|
||||||
channel_id: 'cli',
|
|
||||||
correlation_id: 'corr-1',
|
|
||||||
approval_ref: 'approval-1',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails closed when Matrix whoami does not match the configured native identity', async (): Promise<void> => {
|
|
||||||
const fetchImpl = vi
|
|
||||||
.fn<MatrixFetchLike>()
|
|
||||||
.mockResolvedValue(response(200, { user_id: '@other:example.test' }));
|
|
||||||
const matrix = transport(fetchImpl);
|
|
||||||
|
|
||||||
await expect(matrix.listSessions(scope)).rejects.toMatchObject({ code: 'forbidden' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unbound session IDs without using caller-supplied Matrix room data', async (): Promise<void> => {
|
|
||||||
const fetchImpl = vi
|
|
||||||
.fn<MatrixFetchLike>()
|
|
||||||
.mockResolvedValue(response(200, { user_id: '@mosaic:example.test' }));
|
|
||||||
const matrix = transport(fetchImpl);
|
|
||||||
|
|
||||||
await expect(matrix.verifySession('!attacker-room:example.test', scope)).rejects.toMatchObject({
|
|
||||||
code: 'not_found',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maps replay-cursor events from only the configured remote identity', async (): Promise<void> => {
|
|
||||||
const fetchImpl = vi
|
|
||||||
.fn<MatrixFetchLike>()
|
|
||||||
.mockResolvedValueOnce(response(200, { user_id: '@mosaic:example.test' }))
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
response(200, {
|
|
||||||
next_batch: 'next-cursor',
|
|
||||||
rooms: {
|
|
||||||
join: {
|
|
||||||
'!room:example.test': {
|
|
||||||
timeline: {
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
type: 'mosaic.runtime.event',
|
|
||||||
sender: '@intruder:example.test',
|
|
||||||
content: { 'mosaic.runtime.v1': { type: 'message.delta', content: 'nope' } },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'mosaic.runtime.event',
|
|
||||||
sender: '@native-worker:example.test',
|
|
||||||
origin_server_ts: 1_784_246_400_000,
|
|
||||||
content: {
|
|
||||||
'mosaic.runtime.v1': {
|
|
||||||
session_id: 'native-1',
|
|
||||||
type: 'message.delta',
|
|
||||||
content: 'accepted',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const matrix = transport(fetchImpl);
|
|
||||||
|
|
||||||
const events = [];
|
|
||||||
for await (const event of matrix.stream('native-1', 'prior-cursor', scope)) events.push(event);
|
|
||||||
|
|
||||||
expect(events).toEqual([
|
|
||||||
{
|
|
||||||
type: 'message.delta',
|
|
||||||
sessionId: 'native-1',
|
|
||||||
cursor: 'next-cursor',
|
|
||||||
occurredAt: '2026-07-17T00:00:00.000Z',
|
|
||||||
content: 'accepted',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(fetchImpl.mock.calls[1]?.[0]).toContain('since=prior-cursor');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,367 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
import type {
|
|
||||||
RuntimeHealth,
|
|
||||||
RuntimeMessage,
|
|
||||||
RuntimeScope,
|
|
||||||
RuntimeSessionState,
|
|
||||||
RuntimeStreamEvent,
|
|
||||||
} from '@mosaicstack/types';
|
|
||||||
|
|
||||||
export type MatrixRuntimeTransportErrorCode =
|
|
||||||
| 'forbidden'
|
|
||||||
| 'invalid_request'
|
|
||||||
| 'not_found'
|
|
||||||
| 'unavailable';
|
|
||||||
|
|
||||||
export class MatrixRuntimeTransportError extends Error {
|
|
||||||
constructor(
|
|
||||||
readonly code: MatrixRuntimeTransportErrorCode,
|
|
||||||
message: string,
|
|
||||||
) {
|
|
||||||
super(message);
|
|
||||||
this.name = MatrixRuntimeTransportError.name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Minimal injectable Matrix fetch surface; it avoids coupling this transport to an SDK. */
|
|
||||||
export interface MatrixFetchLike {
|
|
||||||
(
|
|
||||||
url: string,
|
|
||||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
|
||||||
): Promise<{
|
|
||||||
ok: boolean;
|
|
||||||
status: number;
|
|
||||||
json(): Promise<unknown>;
|
|
||||||
text(): Promise<string>;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A server-configured runtime session binding. Callers never select a Matrix room. */
|
|
||||||
export interface MatrixNativeRuntimeSessionBinding {
|
|
||||||
id: string;
|
|
||||||
runtimeId: string;
|
|
||||||
roomId: string;
|
|
||||||
remoteUserId: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
state: RuntimeSessionState;
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatrixNativeRuntimeTransportOptions {
|
|
||||||
homeserverUrl: string;
|
|
||||||
accessToken: string;
|
|
||||||
/** Matrix user authenticated by the service token. */
|
|
||||||
userId: string;
|
|
||||||
sessions: readonly MatrixNativeRuntimeSessionBinding[];
|
|
||||||
fetchImpl?: MatrixFetchLike;
|
|
||||||
now?: () => Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MatrixSyncEvent {
|
|
||||||
type?: string;
|
|
||||||
sender?: string;
|
|
||||||
origin_server_ts?: number;
|
|
||||||
content?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MatrixSyncResponse {
|
|
||||||
next_batch?: string;
|
|
||||||
rooms?: { join?: Record<string, { timeline?: { events?: MatrixSyncEvent[] } }> };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Concrete Matrix CS-API transport for the native provider. It authenticates
|
|
||||||
* the configured sender before each operation and resolves rooms exclusively
|
|
||||||
* from configured bindings, preventing client-selected room/identity routing.
|
|
||||||
*/
|
|
||||||
export class MatrixNativeRuntimeTransport {
|
|
||||||
private readonly baseUrl: string;
|
|
||||||
private readonly fetchImpl: MatrixFetchLike;
|
|
||||||
private readonly now: () => Date;
|
|
||||||
private readonly bindings: ReadonlyMap<string, MatrixNativeRuntimeSessionBinding>;
|
|
||||||
|
|
||||||
constructor(private readonly options: MatrixNativeRuntimeTransportOptions) {
|
|
||||||
this.baseUrl = validatedHomeserverUrl(options.homeserverUrl);
|
|
||||||
if (!options.accessToken.trim()) {
|
|
||||||
throw new MatrixRuntimeTransportError('invalid_request', 'Matrix access token is required');
|
|
||||||
}
|
|
||||||
if (!options.userId.trim()) {
|
|
||||||
throw new MatrixRuntimeTransportError('invalid_request', 'Matrix user identity is required');
|
|
||||||
}
|
|
||||||
this.bindings = new Map(
|
|
||||||
options.sessions.map((binding) => [binding.id, Object.freeze({ ...binding })]),
|
|
||||||
);
|
|
||||||
if (this.bindings.size !== options.sessions.length) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'invalid_request',
|
|
||||||
'Matrix runtime session IDs must be unique',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
this.fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as MatrixFetchLike);
|
|
||||||
this.now = options.now ?? (() => new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
|
|
||||||
try {
|
|
||||||
const versions = await this.request('/_matrix/client/versions', { method: 'GET' });
|
|
||||||
if (!versions.ok) {
|
|
||||||
return this.healthResult('down', `versions HTTP ${versions.status}`);
|
|
||||||
}
|
|
||||||
await this.assertIdentity();
|
|
||||||
return this.healthResult('healthy');
|
|
||||||
} catch (error: unknown) {
|
|
||||||
return this.healthResult('down', message(error));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async listSessions(scope: RuntimeScope): Promise<MatrixNativeRuntimeSessionBinding[]> {
|
|
||||||
await this.assertIdentity();
|
|
||||||
void scope;
|
|
||||||
return [...this.bindings.values()].map((binding) => ({ ...binding }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifySession(
|
|
||||||
sessionId: string,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): Promise<MatrixNativeRuntimeSessionBinding> {
|
|
||||||
await this.assertIdentity();
|
|
||||||
void scope;
|
|
||||||
const binding = this.bindings.get(sessionId);
|
|
||||||
if (!binding) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'not_found',
|
|
||||||
'Matrix runtime session is not configured',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return { ...binding };
|
|
||||||
}
|
|
||||||
|
|
||||||
async *stream(
|
|
||||||
sessionId: string,
|
|
||||||
cursor: string | undefined,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
): AsyncIterable<RuntimeStreamEvent> {
|
|
||||||
const binding = await this.verifySession(sessionId, scope);
|
|
||||||
const query = new URLSearchParams({ timeout: '0' });
|
|
||||||
if (cursor?.trim()) query.set('since', cursor);
|
|
||||||
const response = await this.request(`/_matrix/client/v3/sync?${query.toString()}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'unavailable',
|
|
||||||
`Matrix sync failed: HTTP ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as MatrixSyncResponse;
|
|
||||||
const nextCursor = payload.next_batch?.trim() || cursor?.trim() || 'initial';
|
|
||||||
const events = payload.rooms?.join?.[binding.roomId]?.timeline?.events ?? [];
|
|
||||||
for (const event of events) {
|
|
||||||
const normalized = normalizeEvent(event, binding, nextCursor);
|
|
||||||
if (normalized) yield normalized;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
|
||||||
const binding = await this.verifySession(sessionId, scope);
|
|
||||||
const txnId = transactionId('send', binding.id, message.idempotencyKey);
|
|
||||||
const response = await this.request(
|
|
||||||
`/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/m.room.message/${encodeURIComponent(txnId)}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
msgtype: 'm.text',
|
|
||||||
body: message.content,
|
|
||||||
'mosaic.runtime.v1': metadata(binding, scope, message.idempotencyKey),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'unavailable',
|
|
||||||
`Matrix message delivery failed: HTTP ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
|
||||||
const binding = await this.verifySession(sessionId, scope);
|
|
||||||
const txnId = transactionId('terminate', binding.id, `${approvalRef}:${scope.correlationId}`);
|
|
||||||
const response = await this.request(
|
|
||||||
`/_matrix/client/v3/rooms/${encodeURIComponent(binding.roomId)}/send/mosaic.runtime.terminate/${encodeURIComponent(txnId)}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
body: JSON.stringify({
|
|
||||||
...metadata(binding, scope),
|
|
||||||
approval_ref: approvalRef,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'unavailable',
|
|
||||||
`Matrix termination delivery failed: HTTP ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertIdentity(): Promise<void> {
|
|
||||||
let response: Awaited<ReturnType<MatrixFetchLike>>;
|
|
||||||
try {
|
|
||||||
response = await this.request('/_matrix/client/v3/account/whoami', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: this.authHeaders(),
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
throw new MatrixRuntimeTransportError('unavailable', message(error));
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'forbidden',
|
|
||||||
`Matrix whoami failed: HTTP ${response.status}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const body = (await response.json()) as { user_id?: unknown };
|
|
||||||
if (body.user_id !== this.options.userId) {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'forbidden',
|
|
||||||
'Matrix whoami identity does not match configuration',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private request(
|
|
||||||
path: string,
|
|
||||||
init: { method?: string; headers?: Record<string, string>; body?: string },
|
|
||||||
) {
|
|
||||||
return this.fetchImpl(`${this.baseUrl}${path}`, init);
|
|
||||||
}
|
|
||||||
|
|
||||||
private authHeaders(): Record<string, string> {
|
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${this.options.accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private healthResult(status: RuntimeHealth['status'], detail?: string): RuntimeHealth {
|
|
||||||
return { status, checkedAt: this.now().toISOString(), ...(detail ? { detail } : {}) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function metadata(
|
|
||||||
binding: MatrixNativeRuntimeSessionBinding,
|
|
||||||
scope: RuntimeScope,
|
|
||||||
idempotencyKey?: string,
|
|
||||||
): Record<string, string> {
|
|
||||||
return {
|
|
||||||
session_id: binding.id,
|
|
||||||
runtime_id: binding.runtimeId,
|
|
||||||
actor_id: scope.actorId,
|
|
||||||
tenant_id: scope.tenantId,
|
|
||||||
channel_id: scope.channelId,
|
|
||||||
correlation_id: scope.correlationId,
|
|
||||||
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function transactionId(kind: 'send' | 'terminate', sessionId: string, identity: string): string {
|
|
||||||
return `mosaic-${kind}-${createHash('sha256').update(`${sessionId}\u0000${identity}`).digest('base64url')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEvent(
|
|
||||||
event: MatrixSyncEvent,
|
|
||||||
binding: MatrixNativeRuntimeSessionBinding,
|
|
||||||
cursor: string,
|
|
||||||
): RuntimeStreamEvent | undefined {
|
|
||||||
if (event.type !== 'mosaic.runtime.event' || event.sender !== binding.remoteUserId)
|
|
||||||
return undefined;
|
|
||||||
const payload = event.content?.['mosaic.runtime.v1'];
|
|
||||||
if (
|
|
||||||
!isRecord(payload) ||
|
|
||||||
payload['session_id'] !== binding.id ||
|
|
||||||
typeof payload['type'] !== 'string'
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const occurredAt =
|
|
||||||
typeof payload['occurred_at'] === 'string'
|
|
||||||
? payload['occurred_at']
|
|
||||||
: new Date(event.origin_server_ts ?? 0).toISOString();
|
|
||||||
const eventCursor = typeof payload['cursor'] === 'string' ? payload['cursor'] : cursor;
|
|
||||||
switch (payload['type']) {
|
|
||||||
case 'session.state':
|
|
||||||
return isState(payload['state'])
|
|
||||||
? {
|
|
||||||
type: 'session.state',
|
|
||||||
sessionId: binding.id,
|
|
||||||
cursor: eventCursor,
|
|
||||||
occurredAt,
|
|
||||||
state: payload['state'],
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
case 'message.delta':
|
|
||||||
return typeof payload['content'] === 'string'
|
|
||||||
? {
|
|
||||||
type: 'message.delta',
|
|
||||||
sessionId: binding.id,
|
|
||||||
cursor: eventCursor,
|
|
||||||
occurredAt,
|
|
||||||
content: payload['content'],
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
case 'message.complete':
|
|
||||||
return typeof payload['message_id'] === 'string'
|
|
||||||
? {
|
|
||||||
type: 'message.complete',
|
|
||||||
sessionId: binding.id,
|
|
||||||
cursor: eventCursor,
|
|
||||||
occurredAt,
|
|
||||||
messageId: payload['message_id'],
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isState(value: unknown): value is RuntimeSessionState {
|
|
||||||
return (
|
|
||||||
value === 'starting' ||
|
|
||||||
value === 'active' ||
|
|
||||||
value === 'idle' ||
|
|
||||||
value === 'stopped' ||
|
|
||||||
value === 'failed'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatedHomeserverUrl(value: string): string {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(value);
|
|
||||||
} catch {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'invalid_request',
|
|
||||||
'Matrix homeserver URL must be absolute',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (url.protocol !== 'https:') {
|
|
||||||
throw new MatrixRuntimeTransportError(
|
|
||||||
'invalid_request',
|
|
||||||
'Matrix homeserver URL must use HTTPS',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return url.toString().replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function message(error: unknown): string {
|
|
||||||
return error instanceof Error ? error.message : 'Matrix transport request failed';
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ export const VERSION = '0.0.0';
|
|||||||
|
|
||||||
export * from './fleet/interaction-service-profile.js';
|
export * from './fleet/interaction-service-profile.js';
|
||||||
export * from './fleet/tmux-runtime-transport.js';
|
export * from './fleet/tmux-runtime-transport.js';
|
||||||
export * from './fleet/matrix-native-runtime-transport.js';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
backgroundUpdateCheck,
|
backgroundUpdateCheck,
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
import {
|
|
||||||
attachInteractionSession,
|
|
||||||
sendInteractionMessage,
|
|
||||||
stopInteractionSession,
|
|
||||||
} from './gateway-api.js';
|
|
||||||
|
|
||||||
const gateway = 'https://gateway.example.test';
|
|
||||||
const cookie = 'session=test';
|
|
||||||
const base = { agentName: 'Nova', correlationId: 'corr-1', sessionId: 'session-1' };
|
|
||||||
|
|
||||||
afterEach(() => vi.unstubAllGlobals());
|
|
||||||
|
|
||||||
/** Unit coverage: the TUI preserves a non-success gateway response in its CLI error. */
|
|
||||||
describe('interaction gateway error mapping', (): void => {
|
|
||||||
it.each([
|
|
||||||
{
|
|
||||||
name: 'attach unauthorized',
|
|
||||||
response: { status: 401, message: 'Invalid or expired session' },
|
|
||||||
invoke: (): Promise<unknown> => attachInteractionSession(gateway, cookie, base),
|
|
||||||
expected:
|
|
||||||
'Failed to attach interaction session (401): {"message":"Invalid or expired session"}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'send invalid request',
|
|
||||||
response: { status: 403, message: 'Content and idempotency key are required' },
|
|
||||||
invoke: (): Promise<unknown> =>
|
|
||||||
sendInteractionMessage(gateway, cookie, {
|
|
||||||
...base,
|
|
||||||
content: 'hello',
|
|
||||||
idempotencyKey: 'message-1',
|
|
||||||
}),
|
|
||||||
expected:
|
|
||||||
'Failed to send interaction message (403): {"message":"Content and idempotency key are required"}',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'stop forbidden',
|
|
||||||
response: { status: 403, message: 'Runtime termination approval denied' },
|
|
||||||
invoke: (): Promise<unknown> =>
|
|
||||||
stopInteractionSession(gateway, cookie, { ...base, approvalRef: 'approval-1' }),
|
|
||||||
expected:
|
|
||||||
'Failed to stop interaction session (403): {"message":"Runtime termination approval denied"}',
|
|
||||||
},
|
|
||||||
])(
|
|
||||||
'$name preserves the typed gateway denial in the CLI error',
|
|
||||||
async ({ response, invoke, expected }) => {
|
|
||||||
vi.stubGlobal(
|
|
||||||
'fetch',
|
|
||||||
vi.fn(
|
|
||||||
async () =>
|
|
||||||
new Response(JSON.stringify({ message: response.message }), {
|
|
||||||
status: response.status,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(invoke()).rejects.toThrow(expected);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -6,24 +6,6 @@ export type RuntimeCapability =
|
|||||||
| 'session.attach'
|
| 'session.attach'
|
||||||
| 'session.terminate';
|
| 'session.terminate';
|
||||||
export type RuntimeSessionState = 'starting' | 'active' | 'idle' | 'stopped' | 'failed';
|
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';
|
export type RuntimeAttachMode = 'read' | 'control';
|
||||||
|
|
||||||
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
/** Server-derived immutable authority context. Client identity fields are intentionally absent. */
|
||||||
|
|||||||
Reference in New Issue
Block a user