Compare commits

..

18 Commits

Author SHA1 Message Date
Mos (W-jarvis)
7b1ad5ca82 tools: add mos-comms cross-agent git-relay package
Portable bash utility for two Mos orchestrators on separate hosts to relay
via a shared git branch: one-file-per-message (conflict-free), mechanical
systemd-timer poll, STATIC tmux wake-injection (no untrusted data in send-keys),
3x rebase-retry send, first-poll baseline (no history dump). See
tools/mos-comms/MOS-COMMS-PROTOCOL.md for the spec, threat model, and install.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:02:30 -05:00
d077183554 docs(tess): remediate M5 qualification findings (#750)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 19:59:38 +00:00
405984af5a De-hardcode orchestrator and interaction agent names (#748)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 18:59:27 +00:00
8dd4e9d541 docs(tess): ledger sync m4 — M5-003 done, #745/#746 merged (#749)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 18:14:24 +00:00
bc8016c831 docs(#744): complete Tess documentation gate (#746)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 17:44:17 +00:00
e72388b2cb docs(tess): ledger sync m3 — M5-001 + M5-002 done (#745)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 16:14:23 +00:00
6345dbfcf2 feat(agent): add Matrix native runtime provider (#744)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 15:29:37 +00:00
c6e3cfbd95 docs(tess): ledger sync — W-001 3-of-3 merged, M5-002 approved, M5-001 in TDD (#743)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-07-13 15:29:33 +00:00
5789711ee0 docs(tess): add migration evidence set (#742)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:14:29 +00:00
f40e6ba388 docs(tess): sync M4 tracking to merged reality (M4 in-progress / gate-pending) (#741)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 15:00:15 +00:00
b7b0f508e6 feat(gateway): register Hermes runtime provider (#740)
Some checks failed
ci/woodpecker/push/publish Pipeline failed
ci/woodpecker/push/ci Pipeline was successful
2026-07-13 14:45:08 +00:00
3378b857eb feat(memory): bind operator plugin to agent sessions (#739)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 13:44:20 +00:00
e2376190e5 feat(gateway): expose Mos coordination boundary (#737)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:44 +00:00
cca6aaf947 feat(agent): add Hermes transitional capability matrix (#738)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-07-13 13:14:43 +00:00
2363f155b4 feat(memory): add operator retrieval plugin (#736)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 12:44:31 +00:00
76325ca3f2 feat(tess): add Mos coordination boundary (#735)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 11:59:16 +00:00
9e5b9188ce feat(agent): add transitional Hermes runtime adapter (#734)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-13 11:29:27 +00:00
f1c6b37b46 fix(tess): route bare Discord approvals (#733)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-07-13 10:29:10 +00:00
84 changed files with 5359 additions and 145 deletions

View File

@@ -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 { TessDurableSessionService } from '../../agent/tess-durable-session.service.js'; import { DurableSessionService } from '../../agent/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('Tess Discord/CLI durable-session integration', () => { describe('interaction 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('Tess Discord/CLI durable-session integration', () => {
}, },
]); ]);
const durable = new TessDurableSessionService( const durable = new DurableSessionService(
new InMemoryDurableSessionStore() as never, new InMemoryDurableSessionStore() as never,
{} as never, {} as never,
); );

View File

@@ -12,18 +12,24 @@ type AgentServiceInternals = {
creating: Map<string, Promise<AgentSession>>; creating: Map<string, Promise<AgentSession>>;
}; };
function makeService(): AgentService { function makeService(operatorMemory: unknown = null): 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,
{} as never, { getToolDefinitions: vi.fn(() => []) } as never,
{} as never, { loadForSession: vi.fn(async () => ({ metaTools: [], promptAdditions: [] })) } as never,
null, null,
null, null,
{ collect: vi.fn().mockResolvedValue(undefined) } as never, { collect: vi.fn().mockResolvedValue(undefined) } as never,
operatorMemory as never,
); );
} }
@@ -120,6 +126,37 @@ describe('AgentService owner/tenant scope enforcement', () => {
expect(internals(service).sessions.has(CONVERSATION_ID)).toBe(false); 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();

View File

@@ -1,5 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } 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 { TessDurableSessionRepository } from './tess-durable-session.repository.js'; import { DurableSessionRepository } from './durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js'; import { DurableSessionService } from './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,6 +20,7 @@ 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,
@@ -28,6 +29,12 @@ 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],
@@ -37,11 +44,11 @@ import {
RoutingService, RoutingService,
RoutingEngineService, RoutingEngineService,
SkillLoaderService, SkillLoaderService,
TessDurableSessionRepository, DurableSessionRepository,
TessDurableSessionService, DurableSessionService,
{ {
provide: AGENT_RUNTIME_PROVIDER_REGISTRY, provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), useFactory: createGatewayRuntimeProviderRegistry,
}, },
RuntimeProviderAuditService, RuntimeProviderAuditService,
{ {
@@ -69,7 +76,7 @@ import {
RoutingService, RoutingService,
RoutingEngineService, RoutingEngineService,
SkillLoaderService, SkillLoaderService,
TessDurableSessionService, DurableSessionService,
RuntimeProviderService, RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY, AGENT_RUNTIME_PROVIDER_REGISTRY,
], ],

View File

@@ -15,9 +15,10 @@ 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 } from '@mosaicstack/memory'; import type { Memory, OperatorMemoryPlugin } 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';
@@ -135,6 +136,9 @@ 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,
) {} ) {}
/** /**
@@ -146,6 +150,7 @@ 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),
@@ -154,6 +159,9 @@ 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),
@@ -228,6 +236,7 @@ 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(
@@ -267,7 +276,15 @@ 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 sandboxTools = this.buildToolsForSandbox(sandboxDir, mergedOptions?.userId); const sessionUserId = mergedOptions?.userId;
const sessionTenantId = this.tenantIdFor(sessionUserId, mergedOptions?.tenantId);
const sandboxTools = this.buildToolsForSandbox(
sandboxDir,
sessionUserId,
sessionUserId && sessionTenantId
? { tenantId: sessionTenantId, ownerId: sessionUserId, sessionId }
: undefined,
);
// Combine static tools with dynamically discovered MCP client tools and skill tools // Combine static tools with dynamically discovered MCP client tools and skill tools
const mcpTools = this.mcpClientService.getToolDefinitions(); const mcpTools = this.mcpClientService.getToolDefinitions();
@@ -362,7 +379,7 @@ export class AgentService implements OnModuleDestroy {
sandboxDir, sandboxDir,
allowedTools, allowedTools,
userId: mergedOptions?.userId, userId: mergedOptions?.userId,
tenantId: this.tenantIdFor(mergedOptions?.userId, mergedOptions?.tenantId), tenantId: sessionTenantId,
agentConfigId: mergedOptions?.agentConfigId, agentConfigId: mergedOptions?.agentConfigId,
agentName: resolvedAgentName, agentName: resolvedAgentName,
metrics: { metrics: {

View File

@@ -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 TessProviderOutboxDto { export interface ProviderOutboxDto {
sessionId: string; sessionId: string;
idempotencyKey: string; idempotencyKey: string;
correlationId: string; correlationId: string;

View File

@@ -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 { TessDurableSessionRepository } from './tess-durable-session.repository.js'; import { DurableSessionRepository } from './durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js'; import { DurableSessionService } from './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('TessDurableSessionRepository', () => { describe('DurableSessionRepository', () => {
let dataDir: string | undefined; let dataDir: string | undefined;
let handle: DbHandle; let handle: DbHandle;
let previousAuthSecret: string | undefined; let previousAuthSecret: string | undefined;
@@ -48,9 +48,7 @@ describe('TessDurableSessionRepository', () => {
}); });
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( const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
new TessDurableSessionRepository(handle.db),
);
await beforeRestart.create(IDENTITY); await beforeRestart.create(IDENTITY);
await beforeRestart.receive({ await beforeRestart.receive({
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
await handle.close(); await handle.close();
handle = createPgliteDb(dataDir!); handle = createPgliteDb(dataDir!);
const afterRestart = new DurableSessionCoordinator(new TessDurableSessionRepository(handle.db)); const afterRestart = new DurableSessionCoordinator(new DurableSessionRepository(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[] = [];
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db)); const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY); await coordinator.create(IDENTITY);
await coordinator.receive({ await coordinator.receive({
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db)); const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(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'];
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db)); const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY); await coordinator.create(IDENTITY);
const input = { const input = {
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db)); const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY); await coordinator.create(IDENTITY);
const inbox = { const inbox = {
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db)); const coordinator = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await coordinator.create(IDENTITY); await coordinator.create(IDENTITY);
await coordinator.receive({ await coordinator.receive({
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db); const repository = new DurableSessionRepository(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 TessDurableSessionService(repository, runtimeProviders as never); const service = new DurableSessionService(repository, runtimeProviders as never);
const input = { const input = {
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect', idempotencyKey: 'live-effect',
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db); const repository = new DurableSessionRepository(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 TessDurableSessionService(repository, runtimeProviders as never); const service = new DurableSessionService(repository, runtimeProviders as never);
const input = { const input = {
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect', idempotencyKey: 'mismatch-effect',
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
}, 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 TessDurableSessionRepository(handle.db); const repository = new DurableSessionRepository(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 TessDurableSessionService(repository, runtimeProviders as never); const service = new DurableSessionService(repository, runtimeProviders as never);
const first = { const first = {
sessionId: IDENTITY.sessionId, sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one', idempotencyKey: 'scoped-effect-one',

View File

@@ -33,7 +33,7 @@ import type {
import { DB } from '../database/database.module.js'; import { DB } from '../database/database.module.js';
@Injectable() @Injectable()
export class TessDurableSessionRepository implements DurableSessionStore { export class DurableSessionRepository 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 TessDurableSessionRepository 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 Tess session identity conflict: ${identity.sessionId}`); throw new Error(`Durable 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 TessDurableSessionRepository implements DurableSessionStore {
), ),
) )
.limit(1); .limit(1);
if (!existing[0]) throw new Error(`Durable Tess inbox enqueue failed: ${input.idempotencyKey}`); if (!existing[0]) throw new Error(`Durable 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 Tess inbox idempotency conflict: ${input.idempotencyKey}`); throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
} }
return { accepted: false, status: entry.status }; return { accepted: false, status: entry.status };
} }
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
), ),
) )
.limit(1); .limit(1);
if (!existing[0]) if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
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 Tess outbox idempotency conflict: ${input.idempotencyKey}`); throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
} }
return { accepted: false, status: entry.status }; return { accepted: false, status: entry.status };
} }
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository 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 Tess checkpoint identity conflict: ${input.checkpointId}`); throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
} }
} }
@@ -360,7 +359,7 @@ export class TessDurableSessionRepository 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 Tess handoff checkpoint is unavailable: ${input.checkpointId}`); throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
} }
const inserted = await this.db const inserted = await this.db
.insert(interactionHandoffs) .insert(interactionHandoffs)
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository 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 Tess handoff identity conflict: ${input.handoffId}`); throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
} }
} }

View File

@@ -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 { TessProviderOutboxDto } from './tess-durable-session.dto.js'; import type { ProviderOutboxDto } from './durable-session.dto.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js'; import { DurableSessionRepository } from './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 Tess state machine. It deliberately * Scoped gateway boundary for the canonical durable session 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 TessDurableSessionService { export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator; private readonly coordinator: DurableSessionCoordinator;
constructor( constructor(
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository, @Inject(DurableSessionRepository) repository: DurableSessionRepository,
@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 TessDurableSessionService {
identity.ownerId !== context.actorScope.userId || identity.ownerId !== context.actorScope.userId ||
identity.tenantId !== context.actorScope.tenantId identity.tenantId !== context.actorScope.tenantId
) { ) {
throw new ForbiddenException('Durable Tess enrollment scope mismatch'); throw new ForbiddenException('Durable session enrollment scope mismatch');
} }
await this.coordinator.create(identity); await this.coordinator.create(identity);
} }
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> { async queueProviderSend(input: ProviderOutboxDto): 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 TessDurableSessionService {
}); });
} }
async dispatchProviderOutbox(sessionId: string, input: TessProviderOutboxDto): Promise<void> { async dispatchProviderOutbox(sessionId: string, input: ProviderOutboxDto): Promise<void> {
if (sessionId !== input.sessionId) { if (sessionId !== input.sessionId) {
throw new ForbiddenException('Durable Tess outbox session mismatch'); throw new ForbiddenException('Durable 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 TessDurableSessionService {
} }
/** 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: TessProviderOutboxDto): Promise<void> { async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): 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 TessDurableSessionService {
private assertOutboxScope( private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string }, entry: { kind: string; correlationId: string; channelId: string },
input: TessProviderOutboxDto, input: ProviderOutboxDto,
): 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 Tess outbox scope or correlation mismatch'); throw new ForbiddenException('Durable outbox scope or correlation mismatch');
} }
} }
private assertScope(ownerId: string, tenantId: string, input: TessProviderOutboxDto): void { private assertScope(ownerId: string, tenantId: string, input: ProviderOutboxDto): 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 Tess session scope or correlation mismatch'); throw new ForbiddenException('Durable session scope or correlation mismatch');
} }
} }
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,10 @@
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 { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js'; import {
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';
@@ -39,6 +43,32 @@ 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);

View File

@@ -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 { TessDurableSessionService } from './tess-durable-session.service.js'; import { DurableSessionService } from './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(TessDurableSessionService) private readonly durable: TessDurableSessionService, @Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {} ) {}
@Get('sessions') @Get('sessions')
@@ -51,6 +51,20 @@ 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,

View File

@@ -17,6 +17,8 @@ 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';
@@ -28,7 +30,8 @@ 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. */
@@ -71,6 +74,15 @@ 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();
@@ -151,6 +163,25 @@ 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,

View File

@@ -0,0 +1,41 @@
import { describe, expect, it, vi } from 'vitest';
import { createMemoryTools } from './memory-tools.js';
describe('createMemoryTools operator retrieval binding', () => {
const memory = {
insights: { searchByEmbedding: vi.fn(), create: vi.fn() },
preferences: { findByUserAndCategory: vi.fn(), findByUser: vi.fn(), upsert: vi.fn() },
};
const scope = { tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' };
it('uses the configured plugin with the server-derived scope for retrieval and capture', async () => {
const plugin = {
search: vi.fn(async () => []),
capture: vi.fn(async () => ({ id: 'insight-1' })),
};
const tools = createMemoryTools(memory as never, null, 'owner-a', {
plugin: plugin as never,
scope,
});
await tools
.find((tool) => tool.name === 'memory_search')!
.execute('call-1', { query: 'plans' }, undefined, undefined, {} as never);
await tools
.find((tool) => tool.name === 'memory_save_insight')!
.execute(
'call-2',
{ content: 'secret', category: 'decision' },
undefined,
undefined,
{} as never,
);
expect(plugin.search).toHaveBeenCalledWith(scope, 'plans', 5);
expect(plugin.capture).toHaveBeenCalledWith(scope, {
content: 'secret',
source: 'agent',
category: 'decision',
});
});
});

View File

@@ -1,7 +1,11 @@
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 { Memory } from '@mosaicstack/memory'; import type {
import type { EmbeddingProvider } from '@mosaicstack/memory'; EmbeddingProvider,
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.
@@ -13,8 +17,10 @@ import type { EmbeddingProvider } from '@mosaicstack/memory';
export function createMemoryTools( export function createMemoryTools(
memory: Memory, memory: Memory,
embeddingProvider: EmbeddingProvider | null, embeddingProvider: EmbeddingProvider | null,
/** Authenticated user ID from the session. All memory operations are scoped to this user. */ /** Authenticated user ID from the session. All preference operations are scoped to this user. */
sessionUserId: string | undefined, 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() {
@@ -46,6 +52,14 @@ 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: [
@@ -158,6 +172,18 @@ 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);

View File

@@ -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 { TessDurableSessionService } from '../agent/tess-durable-session.service.js'; import { DurableSessionService } from '../agent/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(TessDurableSessionService) @Inject(DurableSessionService)
private readonly durableSessions: TessDurableSessionService | null = null, private readonly durableSessions: DurableSessionService | 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,

View File

@@ -1,10 +1,32 @@
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: [CoordService], providers: [
controllers: [CoordController], CoordService,
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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,8 +3,10 @@ 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';
@@ -14,6 +16,9 @@ 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';
@@ -38,9 +43,24 @@ 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, EmbeddingService], exports: [MEMORY, MEMORY_ADAPTER, OPERATOR_MEMORY_PLUGIN, EmbeddingService],
}) })
export class MemoryModule {} export class MemoryModule {}

32
docs/SITEMAP.md Normal file
View File

@@ -0,0 +1,32 @@
# 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)

389
docs/openapi-tess.yaml Normal file
View File

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

View File

@@ -0,0 +1,43 @@
# #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.

View File

@@ -0,0 +1,46 @@
# TESS-M4-001 — Mos Coordination
- **Issue/task:** #710 / TESS-M4-001
- **Branch/base:** `feat/tess-mos-coordination` rebased onto `origin/main` `f1c6b37b`
- **Budget assumption:** task estimate 25K; design-first and TDD, with package contract plus gateway boundary only.
## Objective
Implement a transport-neutral coordination contract allowing a configured interaction agent to hand off Mos-owned work, observe activity, and receive results while preventing it from gaining coding/general orchestration authority.
## Plan
1. Document the contract and enforcement-point sketch; request Mos's decision on the initial concrete transport.
2. Add `@mosaicstack/coord` typed handoff/observe/result contracts and denial errors.
3. Add a gateway service which derives actor/tenant/requester identity from trusted context/configuration and validates authority.
4. Add contract and gateway boundary tests for configurable identities, self-delegation, target drift, and cross-tenant read denial.
5. Run focused, cold-cache, baseline tests; independent review; PR lifecycle.
## Design checkpoint — 2026-07-12
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `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.

View File

@@ -0,0 +1,27 @@
# TESS-M4-003 — Operator Plugin Foundations
- **Task:** TESS-M4-003 / TESS-MEM-001
- **Branch/base:** `feat/tess-operator-plugins` rebased on `origin/main` `76325ca3`
- **Scope:** first leaf-package memory/retrieval slice only; no durable inbox ownership, gateway integration, or Mosaic catalog implementation.
## Handoff
Coder4's uncommitted implementation was preserved first in commit `5b99c821` before review. The completion pass corrected the contract so namespace is injected configuration rather than caller-selected scope data, storage keys include tenant/owner/session via collision-safe tuple encoding, and malformed runtime scope values fail closed.
## Delivered boundary
- `OperatorMemoryPlugin` exposes `capture`, `search`, `recent`, `stats`, and `startupContext` through `MemoryAdapter` only.
- Scope is server-derived `{tenantId, ownerId, sessionId}`; adapter and namespace are configuration, not operation input.
- Capture redacts before persistence and records configured instance/namespace/source provenance.
- Wildcard retrieval is documented at the `MemoryAdapter` boundary and implemented by the keyword adapter.
- Startup context uses a bounded 64-result candidate window, then prioritizes project and flat-file provenance before slicing the configured output limit.
- No `Tess` identity is hardcoded in storage keys or defaults; tests use configured `Nova`.
## Verification
- `pnpm --filter @mosaicstack/memory test` — PASS (32 tests)
- `pnpm --filter @mosaicstack/memory typecheck` — PASS
- `pnpm --filter @mosaicstack/memory lint` — PASS
- `pnpm --filter @mosaicstack/memory build` — PASS
- Codex code review — APPROVE after remediation
- Codex security review — no findings after runtime scope-validation remediation

5
docs/tess/ADMIN-GUIDE.md Normal file
View File

@@ -0,0 +1,5 @@
# 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.

View File

@@ -52,6 +52,21 @@ 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.

View File

@@ -0,0 +1,3 @@
# 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.

View File

@@ -0,0 +1,17 @@
# TESS-M4-003 Operator Plugin Sketch
## Memory/retrieval slice — TESS-MEM-001
Introduce a transport-neutral `OperatorMemoryPlugin` in `packages/memory`. The plugin receives a server-derived `{tenantId, ownerId, sessionId}` scope and delegates to a registered `MemoryAdapter`; adapter and namespace are injected configuration, never caller input. Its operations are `capture`, `search`, `recent`, `stats`, and `startupContext`. Results carry configured instance, provenance, and namespace metadata. Capture/redaction occurs before adapter persistence; startup context uses a bounded candidate window ordered so project/flat-file truth takes precedence within returned material.
Registration remains replaceable-adapter based: the existing `registerMemoryAdapter(kind, factory)` / `createMemoryAdapter(config)` seam supplies the injected adapter to `createOperatorMemoryPlugin(config)`. Identity and namespace are configuration data; no interaction-agent name is embedded in keys or defaults.
## Remaining plugin foundations — TESS-PLG-001
- `packages/agent`: capability descriptors for runtime bootstrap, durable inbox/state hooks, and read-only fleet diagnostics. Each capability advertises supported operations and fails closed when absent.
- `packages/mosaic`: a catalog/registration surface for GitOps, fleet diagnostics, runtime bootstrap, Discord, and MCP/skill discovery. Catalog entries describe authority, input schema, and safe/read-only status; they do not invoke provider transports directly.
- Gateway/channel adapters consume these contracts through server-derived actor/tenant context and durable session state, preserving the replaceable-adapter boundary.
## First implementation boundary
The first PR slice should add the operator-memory plugin contract, configuration-injected adapter seam, scope isolation, provenance-bearing retrieval, and tests for namespace isolation plus a differently named configured instance. Durable inbox/outbox remains owned by the existing `DurableSessionCoordinator`; this plugin only supplies bounded context/capture at lifecycle boundaries.

View File

@@ -0,0 +1,8 @@
# 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.

View File

@@ -0,0 +1,12 @@
# 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.

View File

@@ -0,0 +1,11 @@
# 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 |

View File

@@ -0,0 +1,14 @@
# 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.

View File

@@ -0,0 +1,10 @@
# 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.

View File

@@ -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 | not-started | Fleet/Mos boundary and transitional capability matrix demonstrated end-to-end | | 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-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

View File

@@ -0,0 +1,87 @@
# TessMos 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.

View File

@@ -0,0 +1,3 @@
# 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.

View File

@@ -0,0 +1,3 @@
# 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

View File

@@ -33,14 +33,18 @@ 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.
## Existing Findings That Block Tess ## Closed Prerequisite Findings
- Command executor lacks server-side enforcement for declared scopes. The original M1 findings below are closed by landed controls and retained for audit traceability.
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
- MCP schemas accept caller-supplied user identity.
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
- Chat/tool persistence lacks mandatory redaction.
- Sessions/pending Discord output are in-memory and not restart-safe.
- Session GC currently performs globally scoped promotion.
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled. | Former finding | Closed evidence |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 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.

5
docs/tess/USER-GUIDE.md Normal file
View File

@@ -0,0 +1,5 @@
# 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.

View File

@@ -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 | Authority E2E: coding request creates Mos handoff; safe status runs in Tess; no competing worker claim | M4-V | | AC-TESS-04 | TESS-MOS-001, TESS-FLT-001 | M4 contract/gateway native-port handoff → observe → result round trip; configurable identity, target-drift and tenant-denial tests; M4-V fleet authority qualification | M4-001, M4-V |
| AC-TESS-05 | TESS-HRM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix | M4-V | | AC-TESS-05 | TESS-HRM-001, TESS-MEM-001 | Hermes capability contract suite: sessions/stream/send/tree plus Kanban/skills/memory/tools/cron supported-or-denied matrix; operator-memory plugin (TESS-MEM-001) reachable end-to-end — env-configured plugin registered + AgentService session-bound server-derived {tenantId,ownerId,sessionId} scoped search/capture, cross-tenant reuse denied before plugin call (M4-W-001 spine: #736 plugin + #739 consumer) | M4-V |
| AC-TESS-06 | TESS-STA-001, TESS-SEC-008 | Kill/restart/compaction fault injection across inbox/outbox/checkpoint transitions; duplicate side-effect detector | M2-V, M5-V | | AC-TESS-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

View File

@@ -0,0 +1,19 @@
# TESS-HRM-001 — Hermes runtime adapter boundary
## Normalized provider surface
`HermesRuntimeProvider` implements the existing Mosaic-owned `AgentRuntimeProvider` unchanged. Its public surface is therefore `capabilities`, `health`, session list/tree, stream, send, attach/detach, and terminate, accepting only `RuntimeScope`, `RuntimeMessage`, `RuntimeSession`, `RuntimeStreamEvent`, and other types from `@mosaicstack/types`. Provider id is `runtime.hermes`.
The provider receives a narrow injected `HermesRuntimeTransport`, whose method names and inputs may represent Hermes API operations but whose return values are explicitly private `HermesLegacy*` types defined only in `packages/agent/src/hermes-runtime-provider.ts`. Mapping functions convert those private values to Mosaic sessions, state, hierarchy, and stream events. Capability negotiation maps a supplied Hermes feature inventory onto the fixed Mosaic runtime capability vocabulary; no unknown/ambiguous legacy feature is advertised. Unsupported Mosaic operations throw the typed fail-closed `capability_unsupported` provider error before a transport call.
## Boundary line
**Hermes legacy schema ends at `HermesRuntimeTransport` and its private adapter-local `HermesLegacy*` definitions in `packages/agent`.** `packages/types` is never changed to contain a Hermes field, enum, identifier, session shape, status, or capability. `apps/gateway` registers/resolves the provider only through `AgentRuntimeProvider` and receives normalized values only. Identity remains server-derived `RuntimeScope` data and is passed to the injected transport as context, never reconstructed from a legacy response.
## Initial mapping and safety posture
- Hermes conversation/thread identifiers map to opaque Mosaic `RuntimeSession.id`; parent linkage maps only when a known parent exists.
- Hermes status strings map through a closed lookup to `RuntimeSessionState`; unknown statuses become `failed`, never a permissive active state.
- Legacy stream chunks map to `message.delta` / `message.complete`; malformed or unsupported events become a normalized `runtime.error` event.
- Send, attach, and terminate require the normalized capability first. `terminate` continues to be approval-bound by the gateway service; the adapter does not weaken gateway authority.
- Kanban, skills, memory, tools, and cron are capability-inventory entries for this transitional adapter, not additions to the core runtime contract. They are reported as explicitly unsupported until a Mosaic-owned capability contract exists.

View File

@@ -3,7 +3,7 @@ import {
DurableSessionCoordinator, DurableSessionCoordinator,
InMemoryDurableSessionStore, InMemoryDurableSessionStore,
type DurableSessionIdentity, type DurableSessionIdentity,
} from './tess-durable-session.js'; } from './durable-session.js';
const IDENTITY: DurableSessionIdentity = { const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova', agentName: 'Nova',

View File

@@ -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 Tess session not found: ${sessionId}`); super(`Durable 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 Tess handoff not found: ${handoffId}`); if (!handoff) throw new Error(`Durable 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 Tess handoff checkpoint is unavailable: ${handoff.checkpointId}`); throw new Error(`Durable 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 Tess session identity conflict: ${identity.sessionId}`); throw new Error(`Durable 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 Tess inbox idempotency conflict: ${input.idempotencyKey}`); throw new Error(`Durable 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 Tess outbox idempotency conflict: ${input.idempotencyKey}`); throw new Error(`Durable 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 Tess checkpoint identity conflict: ${input.checkpointId}`); throw new Error(`Durable 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 Tess handoff checkpoint is unavailable: ${input.checkpointId}`); throw new Error(`Durable 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 Tess handoff identity conflict: ${input.handoffId}`); throw new Error(`Durable 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 Tess ${kind} entry not found: ${idempotencyKey}`); if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
return entry; return entry;
} }
} }

View File

@@ -0,0 +1,77 @@
import { describe, expect, it, vi } from 'vitest';
import type { RuntimeScope } from '@mosaicstack/types';
import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js';
const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' };
const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({
capabilities: vi.fn(async () => capabilities),
health: vi.fn(async () => ({ status: 'healthy' })),
sessions: vi.fn(async () => [
{
conversation_id: 'child',
agent_id: 'hermes-a',
parent_conversation_id: 'parent',
status: 'running',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
{
conversation_id: 'parent',
agent_id: 'hermes-a',
status: 'unknown',
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
},
]),
stream: async function* () {},
send: vi.fn(),
attach: vi.fn(),
detach: vi.fn(),
terminate: vi.fn(),
});
describe('HermesRuntimeProvider normalization boundary', () => {
it('returns an exhaustive fail-closed transitional capability matrix', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([
{ capability: 'kanban', status: 'unsupported' },
{ capability: 'skills', status: 'unsupported' },
{ capability: 'memory', status: 'unsupported' },
{ capability: 'tools', status: 'unsupported' },
{ capability: 'cron', status: 'unsupported' },
]);
});
it('denies unsupported transitional capabilities without calling Hermes', async () => {
const hermes = transport();
const provider = new HermesRuntimeProvider(hermes);
await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({
code: 'capability_unsupported',
});
expect(hermes.capabilities).not.toHaveBeenCalled();
});
it('normalizes legacy sessions without exposing legacy fields', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.listSessions(scope)).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }),
]),
);
const result = await provider.listSessions(scope);
expect(result[0]).not.toHaveProperty('conversation_id');
});
it('forms normalized hierarchy and fails closed for unbridged operations', async () => {
const provider = new HermesRuntimeProvider(transport());
await expect(provider.getSessionTree(scope)).resolves.toEqual([
expect.objectContaining({
session: expect.objectContaining({ id: 'parent', state: 'failed' }),
children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })],
}),
]);
await expect(
provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope),
).rejects.toMatchObject({ code: 'capability_unsupported' });
});
});

View File

@@ -0,0 +1,217 @@
import type {
AgentRuntimeProvider,
RuntimeAttachHandle,
RuntimeAttachMode,
RuntimeCapability,
RuntimeCapabilitySet,
RuntimeHealth,
RuntimeMessage,
RuntimeScope,
RuntimeSession,
RuntimeSessionState,
RuntimeSessionTree,
RuntimeStreamEvent,
TransitionalCapabilityInventoryEntry,
TransitionalCapabilityInventoryProvider,
TransitionalRuntimeCapability,
} from '@mosaicstack/types';
const HERMES_PROVIDER_ID = 'runtime.hermes';
const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [
'kanban',
'skills',
'memory',
'tools',
'cron',
];
const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
'session.list',
'session.tree',
'session.stream',
'session.send',
'session.attach',
'session.terminate',
];
/** Legacy transport boundary. These shapes are intentionally adapter-local. */
export interface HermesLegacySession {
conversation_id: string;
agent_id: string;
parent_conversation_id?: string;
status: string;
created_at: string;
updated_at: string;
}
export interface HermesRuntimeTransport {
capabilities(scope: RuntimeScope): Promise<string[]>;
health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>;
sessions(scope: RuntimeScope): Promise<HermesLegacySession[]>;
stream(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent>;
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle>;
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
}
export class HermesRuntimeProviderError extends Error {
constructor(
readonly code: 'capability_unsupported' | 'invalid_request',
message: string,
) {
super(message);
this.name = HermesRuntimeProviderError.name;
}
}
/**
* Transitional Hermes adapter. Legacy identifiers and schemas do not cross this
* boundary: callers only observe Mosaic AgentRuntimeProvider contracts.
*/
export class HermesRuntimeProvider
implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider
{
readonly id = HERMES_PROVIDER_ID;
constructor(private readonly transport: HermesRuntimeTransport) {}
/**
* Full AC-TESS-05 migration inventory. These operations are deliberately
* unsupported until their Mosaic-owned plugin contracts exist.
*/
async transitionalCapabilityMatrix(
_scope: RuntimeScope,
): Promise<TransitionalCapabilityInventoryEntry[]> {
return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' }));
}
async assertTransitionalCapability(
capability: TransitionalRuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
const entry = (await this.transitionalCapabilityMatrix(scope)).find(
(candidate) => candidate.capability === capability,
);
if (!entry || entry.status !== 'supported') {
throw new HermesRuntimeProviderError(
'capability_unsupported',
`Hermes transitional capability is unsupported: ${capability}`,
);
}
}
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
const legacyCapabilities = await this.transport.capabilities(scope);
return {
supported: RUNTIME_CAPABILITIES.filter((capability) =>
legacyCapabilities.includes(capability),
),
};
}
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
const health = await this.transport.health(scope);
return {
status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down',
checkedAt: new Date().toISOString(),
...(health.detail ? { detail: health.detail } : {}),
};
}
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
await this.requireCapability('session.list', scope);
return (await this.transport.sessions(scope)).map((session) => this.session(session));
}
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
await this.requireCapability('session.tree', scope);
const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session));
const nodes = new Map<string, RuntimeSessionTree>(
sessions.map((session) => [session.id, { session, children: [] }]),
);
const roots: RuntimeSessionTree[] = [];
for (const session of sessions) {
const node = nodes.get(session.id)!;
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
if (parent) parent.children.push(node);
else roots.push(node);
}
return roots;
}
async *streamSession(
sessionId: string,
cursor: string | undefined,
scope: RuntimeScope,
): AsyncIterable<RuntimeStreamEvent> {
await this.requireCapability('session.stream', scope);
yield* this.transport.stream(sessionId, cursor, scope);
}
async sendMessage(
sessionId: string,
message: RuntimeMessage,
scope: RuntimeScope,
): Promise<void> {
await this.requireCapability('session.send', scope);
if (!message.content.trim())
throw new HermesRuntimeProviderError('invalid_request', 'Message content is required');
await this.transport.send(sessionId, message, scope);
}
async attach(
sessionId: string,
mode: RuntimeAttachMode,
scope: RuntimeScope,
): Promise<RuntimeAttachHandle> {
await this.requireCapability('session.attach', scope);
return this.transport.attach(sessionId, mode, scope);
}
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
await this.transport.detach(attachmentId, scope);
}
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
await this.requireCapability('session.terminate', scope);
if (!approvalRef.trim())
throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required');
await this.transport.terminate(sessionId, approvalRef, scope);
}
private async requireCapability(
capability: RuntimeCapability,
scope: RuntimeScope,
): Promise<void> {
if (!(await this.capabilities(scope)).supported.includes(capability)) {
throw new HermesRuntimeProviderError(
'capability_unsupported',
`Hermes does not bridge ${capability}`,
);
}
}
private session(value: HermesLegacySession): RuntimeSession {
return {
id: value.conversation_id,
providerId: this.id,
runtimeId: value.agent_id,
...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}),
state: state(value.status),
createdAt: value.created_at,
updatedAt: value.updated_at,
};
}
}
function state(value: string): RuntimeSessionState {
return (
(
{ running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record<
string,
RuntimeSessionState
>
)[value] ?? 'failed'
);
}

View File

@@ -2,4 +2,6 @@ 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 './tess-durable-session.js'; export * from './hermes-runtime-provider.js';
export * from './matrix-native-runtime-provider.js';
export * from './durable-session.js';

View File

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

View File

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

View File

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

View File

@@ -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 Mos write authority', async (): Promise<void> => { it('rejects an unverified target before consulting orchestrator 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,7 +220,20 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
expect(fleet.sendMessage).not.toHaveBeenCalled(); expect(fleet.sendMessage).not.toHaveBeenCalled();
}); });
it('passes an exact session ID to the fleet transport only through authorized Mos writes', async (): Promise<void> => { it('uses the role-neutral interaction source label by default', 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),
@@ -228,7 +241,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
}; };
const provider = new TmuxFleetRuntimeProvider({ const provider = new TmuxFleetRuntimeProvider({
transport: fleet, transport: fleet,
sourceLabel: 'tess', sourceLabel: 'operator',
writeAuthority, writeAuthority,
}); });
@@ -246,7 +259,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
scope, scope,
approvalRef: 'approval-1', approvalRef: 'approval-1',
}); });
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess'); expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
expect(fleet.terminate).toHaveBeenCalledWith('coder0'); expect(fleet.terminate).toHaveBeenCalledWith('coder0');
}); });

View File

@@ -64,14 +64,14 @@ export interface FleetWriteAuthorization {
} }
/** /**
* Mos is the only authority that may permit Tess write/control requests to a * The orchestrator is the only authority that may permit interaction-plane 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 Tess writes by design. * invocation; the default authority prevents direct interaction-plane 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 a Mos grant. */ /** Final exact-target authorization; may consume an orchestrator 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 Mos authority decision', 'Fleet writes require an explicit orchestrator 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 Tess writes; all side effects pass through exact transport checks. * direct interaction-plane 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 ?? 'tess'; this.sourceLabel = options.sourceLabel ?? 'interaction';
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;

View File

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

View File

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

View File

@@ -2,6 +2,26 @@ 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,

View File

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

View File

@@ -9,7 +9,8 @@ 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';

View File

@@ -274,6 +274,20 @@ 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);

View File

@@ -132,19 +132,23 @@ 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 words = query const normalizedQuery = query.trim();
.toLowerCase() const matchAll = normalizedQuery === '*';
.split(/\s+/) const words = matchAll
.filter((w) => w.length > 0); ? []
: normalizedQuery
.toLowerCase()
.split(/\s+/)
.filter((word) => word.length > 0);
if (words.length === 0) return []; if (words.length === 0 && !matchAll) return [];
const rows = await this.storage.find<InsightRecord>(INSIGHTS, { userId }); const 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 = 0; let score = matchAll ? 1 : 0;
for (const word of words) { for (const word of words) {
if (content.includes(word)) score++; if (content.includes(word)) score++;
} }

View File

@@ -22,6 +22,13 @@ 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';

View File

@@ -0,0 +1,147 @@
import { describe, expect, it, vi } from 'vitest';
import { createOperatorMemoryPlugin } from './operator-memory-plugin.js';
import type { Insight, InsightSearchResult, NewInsight } from './types.js';
function adapter() {
return {
name: 'test',
embedder: null,
storeInsight: vi.fn(
async (value: NewInsight): Promise<Insight> => ({
...value,
id: '1',
createdAt: new Date(),
}),
),
searchInsights: vi.fn(async (): Promise<InsightSearchResult[]> => []),
getInsight: vi.fn(),
deleteInsight: vi.fn(),
getPreference: vi.fn(),
setPreference: vi.fn(),
deletePreference: vi.fn(),
listPreferences: vi.fn(),
close: vi.fn(),
};
}
describe('OperatorMemoryPlugin', () => {
it('isolates configured namespace storage across server-derived tenant, owner, and session scopes', async () => {
const memory = adapter();
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value,
});
await plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
{ content: 'one', source: 'test', category: 'note' },
);
await plugin.capture(
{ tenantId: 'tenant-b', ownerId: 'owner-a', sessionId: 'session-a' },
{ content: 'two', source: 'test', category: 'note' },
);
await plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-b' },
{ content: 'three', source: 'test', category: 'note' },
);
expect(
memory.storeInsight.mock.calls.map((call: unknown[]) => (call[0] as NewInsight).userId),
).toEqual([
'["operator-memory","tenant-a","owner-a","session-a"]',
'["operator-memory","tenant-b","owner-a","session-a"]',
'["operator-memory","tenant-a","owner-a","session-b"]',
]);
});
it('rejects an incomplete runtime scope before it can produce a shared storage key', async () => {
const memory = adapter();
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value,
});
await expect(
plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: ' ' },
{ content: 'note', source: 'test', category: 'note' },
),
).rejects.toThrow('Operator memory session ID is required');
expect(memory.storeInsight).not.toHaveBeenCalled();
});
it('uses a differently named configured instance in retrieval provenance', async () => {
const memory = adapter();
memory.searchInsights.mockResolvedValue([
{ id: '1', content: 'x', score: 1, metadata: { source: 'project' } },
]);
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value,
});
const results = await plugin.search({ tenantId: 't', ownerId: 'o', sessionId: 's' }, 'x');
expect(results[0]?.provenance).toEqual({
instanceId: 'Nova',
namespace: 'operator-memory',
source: 'project',
});
});
it('orders startup context with project and flat-file truth before retrieved material', async () => {
const memory = adapter();
memory.searchInsights.mockResolvedValue([
{ id: 'retrieval', content: 'retrieval', score: 1 },
{ id: 'flat-file', content: 'flat-file', score: 1, metadata: { source: 'flat-file' } },
{ id: 'project', content: 'project', score: 1, metadata: { source: 'project' } },
]);
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
maxStartupContext: 2,
redact: (value) => value,
});
const context = await plugin.startupContext({
tenantId: 'tenant-a',
ownerId: 'owner-a',
sessionId: 'session-a',
});
expect(context.map((result) => result.id)).toEqual(['project', 'flat-file']);
expect(memory.searchInsights).toHaveBeenCalledWith(expect.any(String), '*', { limit: 64 });
});
it('redacts content before adapter persistence and records configured provenance metadata', async () => {
const memory = adapter();
const plugin = createOperatorMemoryPlugin({
adapter: memory,
instanceId: 'Nova',
namespace: 'operator-memory',
redact: (value) => value.replace('secret', '[REDACTED]'),
});
await plugin.capture(
{ tenantId: 'tenant-a', ownerId: 'owner-a', sessionId: 'session-a' },
{ content: 'secret note', source: 'project', category: 'note' },
);
expect(memory.storeInsight).toHaveBeenCalledWith(
expect.objectContaining({
content: '[REDACTED] note',
metadata: {
instanceId: 'Nova',
namespace: 'operator-memory',
source: 'project',
},
}),
);
});
});

View File

@@ -0,0 +1,154 @@
import type { Insight, InsightSearchResult, MemoryAdapter } from './types.js';
const STARTUP_CONTEXT_CANDIDATE_LIMIT = 64;
/** Immutable server-derived boundary; callers never choose an adapter namespace. */
export interface OperatorMemoryScope {
readonly tenantId: string;
readonly ownerId: string;
readonly sessionId: string;
}
export interface OperatorMemoryConfig {
/** Adapter injection is deployment/lifecycle configuration, never caller input. */
readonly adapter: MemoryAdapter;
/** Configured agent identity; it is metadata rather than a storage key default. */
readonly instanceId: string;
/** Configured storage partition; callers cannot select a namespace. */
readonly namespace: string;
readonly maxStartupContext?: number;
redact(content: string): string;
}
export interface OperatorMemoryResult extends InsightSearchResult {
provenance: { instanceId: string; namespace: string; source: string };
}
export interface OperatorMemoryPlugin {
capture(
scope: OperatorMemoryScope,
input: { content: string; source: string; category: string },
): Promise<Insight>;
search(
scope: OperatorMemoryScope,
query: string,
limit?: number,
): Promise<OperatorMemoryResult[]>;
recent(scope: OperatorMemoryScope, limit?: number): Promise<OperatorMemoryResult[]>;
stats(scope: OperatorMemoryScope): Promise<{ namespace: string; resultCount: number }>;
startupContext(scope: OperatorMemoryScope): Promise<OperatorMemoryResult[]>;
}
function scopedUserId(scope: OperatorMemoryScope, namespace: string): string {
const normalizedScope = normalizeScope(scope);
// JSON tuple encoding avoids delimiter collisions between independently scoped IDs.
return JSON.stringify([
namespace,
normalizedScope.tenantId,
normalizedScope.ownerId,
normalizedScope.sessionId,
]);
}
function normalizeScope(scope: OperatorMemoryScope): OperatorMemoryScope {
if (typeof scope !== 'object' || scope === null) {
throw new Error('Operator memory scope is required');
}
return Object.freeze({
tenantId: requiredScopeId(scope.tenantId, 'tenant ID'),
ownerId: requiredScopeId(scope.ownerId, 'owner ID'),
sessionId: requiredScopeId(scope.sessionId, 'session ID'),
});
}
function requiredScopeId(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim().length === 0) {
throw new Error(`Operator memory ${field} is required`);
}
return value.trim();
}
function compareStartupContext(left: OperatorMemoryResult, right: OperatorMemoryResult): number {
return (
startupSourcePriority(left.provenance.source) - startupSourcePriority(right.provenance.source)
);
}
function startupSourcePriority(source: string): number {
if (source === 'project') return 0;
if (source === 'flat-file') return 1;
return 2;
}
function normalizeConfig(config: OperatorMemoryConfig): OperatorMemoryConfig {
const instanceId = config.instanceId.trim();
const namespace = config.namespace.trim();
const maxStartupContext = config.maxStartupContext ?? 8;
if (instanceId.length === 0 || namespace.length === 0) {
throw new Error('Operator memory instance ID and namespace must be configured');
}
if (!Number.isSafeInteger(maxStartupContext) || maxStartupContext < 1) {
throw new Error('Operator memory startup context limit must be a positive integer');
}
return Object.freeze({ ...config, instanceId, namespace, maxStartupContext });
}
/** Creates a leaf-package, replaceable memory adapter facade. */
export function createOperatorMemoryPlugin(config: OperatorMemoryConfig): OperatorMemoryPlugin {
const pluginConfig = normalizeConfig(config);
const mapResult = (result: InsightSearchResult): OperatorMemoryResult => ({
...result,
provenance: {
instanceId: pluginConfig.instanceId,
namespace: pluginConfig.namespace,
source: String(result.metadata?.['source'] ?? 'retrieval'),
},
});
const search = async (
scope: OperatorMemoryScope,
query: string,
limit = 10,
): Promise<OperatorMemoryResult[]> =>
(
await pluginConfig.adapter.searchInsights(
scopedUserId(scope, pluginConfig.namespace),
query,
{
limit,
},
)
).map(mapResult);
return {
async capture(scope, input) {
return pluginConfig.adapter.storeInsight({
userId: scopedUserId(scope, pluginConfig.namespace),
content: pluginConfig.redact(input.content),
source: input.source,
category: input.category,
relevanceScore: 1,
metadata: {
namespace: pluginConfig.namespace,
instanceId: pluginConfig.instanceId,
source: input.source,
},
});
},
search,
async recent(scope, limit = 10) {
return search(scope, '*', limit);
},
async stats(scope) {
return {
namespace: pluginConfig.namespace,
resultCount: (await search(scope, '*', 100)).length,
};
},
async startupContext(scope) {
const maxStartupContext = pluginConfig.maxStartupContext ?? 8;
// Prioritize authoritative sources within a bounded candidate window.
const candidateLimit = Math.max(maxStartupContext, STARTUP_CONTEXT_CANDIDATE_LIMIT);
const context = await search(scope, '*', candidateLimit);
return [...context].sort(compareStartupContext).slice(0, maxStartupContext);
},
};
}

View File

@@ -49,6 +49,10 @@ 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,

View File

@@ -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 Mos. hands coding or general orchestration work to the orchestrator.
## Boundaries ## Boundaries
- It does not claim Mos-owned coding or general orchestration work. - It does not claim orchestrator-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.

View File

@@ -75,6 +75,14 @@
"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"
}, },

View File

@@ -189,6 +189,36 @@ 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([]);
@@ -1872,7 +1902,61 @@ describe('fleet ps — tenant and host', () => {
}); });
}); });
describe('fleet ps — JSON output shape (FR-6)', () => { describe('fleet ps — aliases and 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');

View File

@@ -81,6 +81,8 @@ 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;
@@ -102,6 +104,8 @@ 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;
@@ -1074,6 +1078,7 @@ 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;
@@ -1755,6 +1760,7 @@ 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,
@@ -1888,7 +1894,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
console.log( console.log(
[ [
row.name.padEnd(18), (row.alias ?? 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),
@@ -2494,6 +2500,8 @@ 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',
@@ -2525,6 +2533,8 @@ 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(
@@ -2827,6 +2837,12 @@ 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;
} }

View File

@@ -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 Mos-owned work through the authorized handoff boundary.', 'Delegate orchestrator-owned work through the authorized handoff boundary.',
].join('\n'); ].join('\n');
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,24 @@ 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. */

View File

@@ -0,0 +1,135 @@
# Mos Cross-Agent Git Communications Protocol
`mos-comms.sh` is a portable, append-only relay for orchestrators on separate
hosts. It uses a git repository supplied by the operator as transport. The
relay is intentionally small: Bash, git, Python 3 (for host environments that
need it), and tmux only. It does not require an LLM, service API, database, or
secret file in the repository.
## Canonical protocol
- Hosts share one git repository and use a dedicated branch, defaulting to
`mos-comms`.
- Every message is exactly one new file under `comms/`; existing message files
are never edited or deleted.
- A sender writes
`comms/<UTC>__from-<AGENT_NAME>__<random>.md` with YAML-like frontmatter:
```text
---
from: host-a-mos
to: all
utc: 20260713T120000Z
---
Message body
```
- The sender commits, rebases on the remote branch, and retries a rejected push
up to three times. Independent files make normal concurrent sends
conflict-free.
- `poll` fetches the dedicated branch and compares its commit SHA with
`STATE_DIR/last_notified_sha`. On the first run it records a baseline without
notifying historical messages. Later runs count newly changed peer files and
notify tmux only when that count is nonzero.
- `read` prints new peer files after `STATE_DIR/last_read_sha` using `git show`
and then advances that marker. Message bodies surface here as data, not code.
## Commands
```bash
mos-comms.sh setup
mos-comms.sh send 'A deliberate message for peers'
mos-comms.sh poll
mos-comms.sh read
mos-comms.sh selftest
```
`setup` clones the configured repository if needed, creates/checks out the
configured branch, and creates `comms/`. The first sender creates the remote
branch if it is not present yet.
`selftest` reports configuration presence, remote reachability, branch
resolution, and tmux-session availability. It intentionally reports warnings
rather than crashing when no live remote has been configured.
## Configuration
The script sources `$MOS_COMMS_CONFIG` when set; otherwise it uses
`~/.config/mos-comms/mos-comms.config`. Copy
`mos-comms.config.example` to that path and set `AGENT_NAME` and
`COMMS_REMOTE` locally. Defaults are:
| Variable | Default |
| --- | --- |
| `COMMS_REPO_DIR` | `~/.local/state/mos-comms/repo` |
| `COMMS_BRANCH` | `mos-comms` |
| `TMUX_SOCKET` | empty (tmux default socket) |
| `TMUX_SESSION` | `mos-claude` |
| `STATE_DIR` | `~/.local/state/mos-comms` |
`GIT_CRED_HELPER` is optional. If configured, every git operation runs with
`git -c credential.helper=$GIT_CRED_HELPER`. Credential values do not belong in
the config or repository.
## Threat model
1. **Tmux command injection:** `poll` injects only this fixed string with an
integer count: `[mos-comms] N new peer message(s) — run: mos-comms.sh read`.
It never interpolates a message body, filename, or author into `send-keys`.
A crafted message cannot become keystrokes.
2. **Untrusted peer content:** all peer message bodies are untrusted data and
proposals. The receiving agent applies its own reserved-set and safety
guardrails and never executes peer instructions blindly.
3. **No mechanical auto-reply:** receiving only produces a notification. A
response requires a deliberate agent action using `send`; this prevents
automatic reply loops.
4. **Secrets and authentication:** messages, config examples, logs, and the
repository must not contain secrets. Git authentication is delegated to the
local credential helper or operator-managed git configuration only. The
script does not print credentials.
5. **Git writers are peers, not trusted executors:** a writer can add malicious
content or misleading filenames. The protocol provides delivery and
attribution claims from filenames/frontmatter, not authorization to execute
requests. Repository access should be limited to intended relay hosts.
## Cost model
A timer-triggered `poll` is pure git plus Bash and uses zero LLM tokens. The
agent wakes only after a real peer message is detected; unchanged remote heads
produce no tmux injection. Reading and replying are deliberate operations.
## New-host install
1. Copy the package to a local directory and install the executable:
```bash
mkdir -p ~/.local/bin ~/.config/mos-comms ~/.config/systemd/user
cp mos-comms.sh ~/.local/bin/mos-comms.sh
chmod 700 ~/.local/bin/mos-comms.sh
cp mos-comms.config.example ~/.config/mos-comms/mos-comms.config
chmod 600 ~/.config/mos-comms/mos-comms.config
```
2. Edit the **local-only** config. Set a unique `AGENT_NAME` and the
operator-provisioned `COMMS_REMOTE`. Configure git credentials separately
(or set a credential-helper name, never a token value).
3. Confirm prerequisites and create the local checkout:
```bash
~/.local/bin/mos-comms.sh selftest
~/.local/bin/mos-comms.sh setup
```
4. Install and enable the per-user timer:
```bash
cp systemd/mos-comms.service systemd/mos-comms.timer ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now mos-comms.timer
systemctl --user list-timers mos-comms.timer
```
5. Send a deliberate test message from one host, then run `read` on the other
host after its poll notification. Keep the relay branch dedicated to this
protocol; do not share it with source-code work.

View File

@@ -0,0 +1,23 @@
# Copy to ~/.config/mos-comms/mos-comms.config (mode 600) or point
# MOS_COMMS_CONFIG at another local-only file. Do not commit this file with
# operator values or credentials.
# Required deployment identity; letters, numbers, dots, and hyphens only.
AGENT_NAME="host-a-mos"
# Required: URL/path of the shared, operator-provisioned git repository.
# Authentication is delegated to git or the optional credential helper below.
COMMS_REMOTE=""
# Optional local paths and branch.
COMMS_REPO_DIR="$HOME/.local/state/mos-comms/repo"
COMMS_BRANCH="mos-comms"
STATE_DIR="$HOME/.local/state/mos-comms"
# Empty selects tmux's default socket. Set only when a named socket is used.
TMUX_SOCKET=""
TMUX_SESSION="mos-claude"
# Optional git credential-helper name/path. Never place credential values here.
# Example: GIT_CRED_HELPER="cache"
GIT_CRED_HELPER=""

291
tools/mos-comms/mos-comms.sh Executable file
View File

@@ -0,0 +1,291 @@
#!/usr/bin/env bash
# Portable, append-only git relay for deliberate cross-agent communication.
set -euo pipefail
CONFIG_PATH="${MOS_COMMS_CONFIG:-$HOME/.config/mos-comms/mos-comms.config}"
CONFIG_PRESENT=0
if [[ -f "$CONFIG_PATH" ]]; then
# Configuration is local operator input. It must not be committed to the relay repo.
# shellcheck source=/dev/null
source "$CONFIG_PATH"
CONFIG_PRESENT=1
fi
AGENT_NAME="${AGENT_NAME:-}"
COMMS_REMOTE="${COMMS_REMOTE:-}"
COMMS_REPO_DIR="${COMMS_REPO_DIR:-$HOME/.local/state/mos-comms/repo}"
COMMS_BRANCH="${COMMS_BRANCH:-mos-comms}"
TMUX_SOCKET="${TMUX_SOCKET:-}"
TMUX_SESSION="${TMUX_SESSION:-mos-claude}"
STATE_DIR="${STATE_DIR:-$HOME/.local/state/mos-comms}"
GIT_CRED_HELPER="${GIT_CRED_HELPER:-}"
usage() {
cat <<'EOF'
Usage: mos-comms.sh setup | poll | read | send <text> | selftest
Messages are append-only files on the configured shared git branch. `poll` is
mechanical: it only checks git and emits a fixed tmux notification.
EOF
}
die() {
printf 'mos-comms: %s\n' "$*" >&2
exit 1
}
warn() {
printf 'mos-comms: warning: %s\n' "$*" >&2
}
gitc() {
if [[ -n "$GIT_CRED_HELPER" ]]; then
git -c credential.helper="$GIT_CRED_HELPER" "$@"
else
git "$@"
fi
}
require_identity() {
[[ -n "$AGENT_NAME" ]] || die 'AGENT_NAME is required in the local config'
[[ "$AGENT_NAME" =~ ^[A-Za-z0-9.-]+$ ]] || die 'AGENT_NAME may contain only letters, numbers, dots, and hyphens'
}
require_remote() {
[[ -n "$COMMS_REMOTE" ]] || die 'COMMS_REMOTE is required in the local config'
}
ensure_state_dir() {
mkdir -p "$STATE_DIR"
chmod 700 "$STATE_DIR"
}
remote_branch_exists() {
gitc -C "$COMMS_REPO_DIR" show-ref --verify --quiet "refs/remotes/origin/$COMMS_BRANCH"
}
setup() {
require_remote
require_identity
mkdir -p "$(dirname "$COMMS_REPO_DIR")"
if [[ ! -d "$COMMS_REPO_DIR/.git" ]]; then
gitc clone "$COMMS_REMOTE" "$COMMS_REPO_DIR"
fi
gitc -C "$COMMS_REPO_DIR" remote set-url origin "$COMMS_REMOTE"
gitc -C "$COMMS_REPO_DIR" fetch origin
if remote_branch_exists; then
# Preserve any local append-only commit that is awaiting a retry; do not
# reset a checked-out branch to origin here.
if gitc -C "$COMMS_REPO_DIR" show-ref --verify --quiet "refs/heads/$COMMS_BRANCH"; then
gitc -C "$COMMS_REPO_DIR" checkout "$COMMS_BRANCH"
else
gitc -C "$COMMS_REPO_DIR" checkout --track -b "$COMMS_BRANCH" "origin/$COMMS_BRANCH"
fi
gitc -C "$COMMS_REPO_DIR" branch --set-upstream-to="origin/$COMMS_BRANCH" "$COMMS_BRANCH"
else
gitc -C "$COMMS_REPO_DIR" checkout -B "$COMMS_BRANCH"
fi
mkdir -p "$COMMS_REPO_DIR/comms"
ensure_state_dir
}
ensure_remote_branch() {
if ! remote_branch_exists; then
warn "origin branch '$COMMS_BRANCH' does not exist yet"
return 1
fi
}
message_is_from_self() {
local file="$1"
[[ "$file" == *"__from-${AGENT_NAME}__"* ]]
}
changed_message_files() {
local from_sha="$1"
local to_sha="$2"
if [[ -n "$from_sha" ]] && gitc -C "$COMMS_REPO_DIR" cat-file -e "$from_sha^{commit}" 2>/dev/null; then
gitc -C "$COMMS_REPO_DIR" diff --name-only "$from_sha" "$to_sha" -- comms/
else
gitc -C "$COMMS_REPO_DIR" ls-tree -r --name-only "$to_sha" -- comms/
fi
}
tmuxc() {
if [[ -n "$TMUX_SOCKET" ]]; then
tmux -L "$TMUX_SOCKET" "$@"
else
tmux "$@"
fi
}
send() {
require_identity
require_remote
[[ $# -ge 1 ]] || die 'send requires message text'
setup
local body="$*"
local utc filename path
utc="$(date -u +%Y%m%dT%H%M%SZ)"
filename="${utc}__from-${AGENT_NAME}__${RANDOM}${RANDOM}.md"
path="$COMMS_REPO_DIR/comms/$filename"
umask 077
{
printf '%s\n' '---'
printf 'from: %s\n' "$AGENT_NAME"
printf '%s\n' 'to: all'
printf 'utc: %s\n' "$utc"
printf '%s\n\n' '---'
printf '%s\n' "$body"
} >"$path"
gitc -C "$COMMS_REPO_DIR" add -- "comms/$filename"
if ! gitc -C "$COMMS_REPO_DIR" config user.name >/dev/null; then
gitc -C "$COMMS_REPO_DIR" config user.name "$AGENT_NAME mos-comms"
fi
if ! gitc -C "$COMMS_REPO_DIR" config user.email >/dev/null; then
gitc -C "$COMMS_REPO_DIR" config user.email "$AGENT_NAME@localhost"
fi
gitc -C "$COMMS_REPO_DIR" commit -m "comms: ${AGENT_NAME} ${utc}"
local attempt
for attempt in 1 2 3; do
if remote_branch_exists; then
gitc -C "$COMMS_REPO_DIR" pull --rebase origin "$COMMS_BRANCH"
fi
if gitc -C "$COMMS_REPO_DIR" push origin "HEAD:refs/heads/$COMMS_BRANCH"; then
printf 'sent: %s\n' "$filename"
return 0
fi
if [[ "$attempt" -lt 3 ]]; then
warn "push rejected; retrying ($attempt/3)"
gitc -C "$COMMS_REPO_DIR" fetch origin
fi
done
die 'push was rejected after 3 attempts; local message commit remains available for retry'
}
poll() {
require_identity
require_remote
setup
gitc -C "$COMMS_REPO_DIR" fetch origin
ensure_remote_branch || return 0
local head marker last_notified
head="$(gitc -C "$COMMS_REPO_DIR" rev-parse "origin/$COMMS_BRANCH")"
marker="$STATE_DIR/last_notified_sha"
if [[ ! -f "$marker" ]]; then
printf '%s\n' "$head" >"$marker"
printf 'poll: baseline recorded; existing history was not notified\n'
return 0
fi
last_notified="$(<"$marker")"
if [[ "$last_notified" == "$head" ]]; then
return 0
fi
local file count=0
while IFS= read -r file; do
[[ -n "$file" ]] || continue
if ! message_is_from_self "$file"; then
((count += 1))
fi
done < <(changed_message_files "$last_notified" "$head")
if ((count > 0)); then
# Intentionally static except for an integer: never inject peer data into tmux.
tmuxc send-keys -t "$TMUX_SESSION" "[mos-comms] $count new peer message(s) — run: mos-comms.sh read"
tmuxc send-keys -t "$TMUX_SESSION" Enter
fi
printf '%s\n' "$head" >"$marker"
}
read_messages() {
require_identity
require_remote
setup
gitc -C "$COMMS_REPO_DIR" fetch origin
ensure_remote_branch || return 0
local head marker last_read file found=0
head="$(gitc -C "$COMMS_REPO_DIR" rev-parse "origin/$COMMS_BRANCH")"
marker="$STATE_DIR/last_read_sha"
last_read=""
if [[ -f "$marker" ]]; then
last_read="$(<"$marker")"
fi
while IFS= read -r file; do
[[ -n "$file" ]] || continue
if message_is_from_self "$file"; then
continue
fi
found=1
printf '%s\n' "===== $file ====="
# Peer content is printed as data. It is never evaluated or sent to tmux.
gitc -C "$COMMS_REPO_DIR" show "origin/$COMMS_BRANCH:$file"
printf '\n'
done < <(changed_message_files "$last_read" "$head")
printf '%s\n' "$head" >"$marker"
if ((found == 0)); then
printf 'No new peer messages.\n'
fi
}
selftest() {
local remote_ok=0 branch_ok=0 tmux_ok=0 config_ok=0
if ((CONFIG_PRESENT == 1)); then
printf 'PASS config present: %s\n' "$CONFIG_PATH"
config_ok=1
else
printf 'FAIL config present: %s\n' "$CONFIG_PATH"
fi
if [[ -z "$COMMS_REMOTE" ]]; then
printf 'WARN remote reachable: COMMS_REMOTE is not configured\n'
elif gitc ls-remote "$COMMS_REMOTE" >/dev/null 2>&1; then
printf 'PASS remote reachable\n'
remote_ok=1
else
printf 'FAIL remote reachable\n'
fi
if ((remote_ok == 1)) && gitc ls-remote --exit-code --heads "$COMMS_REMOTE" "$COMMS_BRANCH" >/dev/null 2>&1; then
printf 'PASS branch resolves: %s\n' "$COMMS_BRANCH"
branch_ok=1
elif [[ -z "$COMMS_REMOTE" ]]; then
printf 'WARN branch resolves: skipped without a remote\n'
else
printf 'FAIL branch resolves: %s\n' "$COMMS_BRANCH"
fi
if tmuxc has-session -t "$TMUX_SESSION" 2>/dev/null; then
printf 'PASS tmux session exists: %s\n' "$TMUX_SESSION"
tmux_ok=1
else
printf 'FAIL tmux session exists: %s\n' "$TMUX_SESSION"
fi
# Report all checks without making a no-remote first-run unusable.
if ((config_ok == 1 && remote_ok == 1 && branch_ok == 1 && tmux_ok == 1)); then
printf 'PASS selftest complete\n'
else
printf 'FAIL selftest incomplete (see checks above)\n'
fi
}
case "${1:-}" in
setup) [[ $# -eq 1 ]] || usage; setup ;;
poll) [[ $# -eq 1 ]] || usage; poll ;;
read) [[ $# -eq 1 ]] || usage; read_messages ;;
send) shift; send "$@" ;;
selftest) [[ $# -eq 1 ]] || usage; selftest ;;
-h|--help|help) usage ;;
*) usage; exit 2 ;;
esac

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Mos cross-host git communications poll
[Service]
Type=oneshot
ExecStart=%h/.local/bin/mos-comms.sh poll

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Poll Mos cross-host git communications
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
[Install]
WantedBy=timers.target