Compare commits

...

3 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
47 changed files with 1199 additions and 331 deletions

View File

@@ -7,7 +7,7 @@ import {
} from '@mosaicstack/discord-plugin';
import { InteractionController } from '../../agent/interaction.controller.js';
import { RuntimeProviderService } from '../../agent/runtime-provider-registry.service.js';
import { TessDurableSessionService } from '../../agent/tess-durable-session.service.js';
import { DurableSessionService } from '../../agent/durable-session.service.js';
import { ChatGateway } from '../../chat/chat.gateway.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(() => {
for (const key of envKeys) {
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,
{} as never,
);

View File

@@ -11,8 +11,8 @@ import { SessionsController } from './sessions.controller.js';
import { AgentConfigsController } from './agent-configs.controller.js';
import { InteractionController } from './interaction.controller.js';
import { RoutingController } from './routing/routing.controller.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
import { CoordModule } from '../coord/coord.module.js';
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
import { SkillsModule } from '../skills/skills.module.js';
@@ -44,8 +44,8 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
TessDurableSessionRepository,
TessDurableSessionService,
DurableSessionRepository,
DurableSessionService,
{
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
useFactory: createGatewayRuntimeProviderRegistry,
@@ -76,7 +76,7 @@ export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegi
RoutingService,
RoutingEngineService,
SkillLoaderService,
TessDurableSessionService,
DurableSessionService,
RuntimeProviderService,
AGENT_RUNTIME_PROVIDER_REGISTRY,
],

View File

@@ -1,7 +1,7 @@
import type { RuntimeProviderRequestContext } from './runtime-provider-registry.service.js';
/** Server-side request for a replay-safe provider message. */
export interface TessProviderOutboxDto {
export interface ProviderOutboxDto {
sessionId: string;
idempotencyKey: string;
correlationId: string;

View File

@@ -6,8 +6,8 @@ import { eq, sql, interactionCheckpoints, interactionInbox } from '@mosaicstack/
import { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createPgliteDb, runPgliteMigrations, type DbHandle } from '@mosaicstack/db';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import { DurableSessionService } from './durable-session.service.js';
const IDENTITY: DurableSessionIdentity = {
agentName: 'Nova',
@@ -18,7 +18,7 @@ const IDENTITY: DurableSessionIdentity = {
runtimeSessionId: 'nova',
};
describe('TessDurableSessionRepository', () => {
describe('DurableSessionRepository', () => {
let dataDir: string | undefined;
let handle: DbHandle;
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 () => {
const beforeRestart = new DurableSessionCoordinator(
new TessDurableSessionRepository(handle.db),
);
const beforeRestart = new DurableSessionCoordinator(new DurableSessionRepository(handle.db));
await beforeRestart.create(IDENTITY);
await beforeRestart.receive({
sessionId: IDENTITY.sessionId,
@@ -92,7 +90,7 @@ describe('TessDurableSessionRepository', () => {
await handle.close();
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 resumedHandoff = await afterRestart.resumeHandoff('handoff-before-kill');
const handled: string[] = [];
@@ -120,7 +118,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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.receive({
sessionId: IDENTITY.sessionId,
@@ -157,7 +155,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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);
const secret = process.env['BETTER_AUTH_SECRET'];
delete process.env['BETTER_AUTH_SECRET'];
@@ -177,7 +175,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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);
const input = {
sessionId: IDENTITY.sessionId,
@@ -227,7 +225,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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);
const inbox = {
sessionId: IDENTITY.sessionId,
@@ -255,7 +253,7 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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.receive({
sessionId: IDENTITY.sessionId,
@@ -293,10 +291,10 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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 runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'live-effect',
@@ -333,10 +331,10 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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 runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const service = new DurableSessionService(repository, runtimeProviders as never);
const input = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'mismatch-effect',
@@ -366,10 +364,10 @@ describe('TessDurableSessionRepository', () => {
}, 30_000);
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 runtimeProviders = { sendMessage: vi.fn().mockResolvedValue(undefined) };
const service = new TessDurableSessionService(repository, runtimeProviders as never);
const service = new DurableSessionService(repository, runtimeProviders as never);
const first = {
sessionId: IDENTITY.sessionId,
idempotencyKey: 'scoped-effect-one',

View File

@@ -33,7 +33,7 @@ import type {
import { DB } from '../database/database.module.js';
@Injectable()
export class TessDurableSessionRepository implements DurableSessionStore {
export class DurableSessionRepository implements DurableSessionStore {
constructor(@Inject(DB) private readonly db: Db) {}
async create(identity: DurableSessionIdentity): Promise<void> {
@@ -51,7 +51,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
const existing = await this.session(identity.sessionId);
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;
// the conversation handle and owner scope remain immutable.
@@ -135,13 +135,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
),
)
.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]);
if (
!sameInbox(entry, record) ||
!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 };
}
@@ -224,14 +224,13 @@ export class TessDurableSessionRepository implements DurableSessionStore {
),
)
.limit(1);
if (!existing[0])
throw new Error(`Durable Tess outbox enqueue failed: ${input.idempotencyKey}`);
if (!existing[0]) throw new Error(`Durable outbox enqueue failed: ${input.idempotencyKey}`);
const entry = toOutbox(existing[0]);
if (
!sameOutbox(entry, record) ||
!matchesContentDigest(existing[0].contentDigest, input.content)
) {
throw new Error(`Durable Tess outbox idempotency conflict: ${input.idempotencyKey}`);
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
}
return { accepted: false, status: entry.status };
}
@@ -338,7 +337,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
!sameCheckpoint(toCheckpoint(existing[0]), checkpoint) ||
!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> {
const checkpoint = await this.findCheckpoint(input.sessionId, input.checkpointId);
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
.insert(interactionHandoffs)
@@ -371,7 +370,7 @@ export class TessDurableSessionRepository implements DurableSessionStore {
const existing = await this.findHandoff(input.handoffId);
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 { DurableSessionCoordinator, type DurableSessionIdentity } from '@mosaicstack/agent';
import type { TessProviderOutboxDto } from './tess-durable-session.dto.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.js';
import type { ProviderOutboxDto } from './durable-session.dto.js';
import { DurableSessionRepository } from './durable-session.repository.js';
import {
RuntimeProviderService,
type RuntimeProviderRequestContext,
} from './runtime-provider-registry.service.js';
/**
* Scoped gateway boundary for the canonical Tess state machine. It deliberately
* Scoped gateway boundary for the canonical durable session state machine. It deliberately
* uses composition: raw state methods cannot be injected into channel, CLI, or
* MCP adapters without a server-derived actor/tenant/correlation context.
*/
@Injectable()
export class TessDurableSessionService {
export class DurableSessionService {
private readonly coordinator: DurableSessionCoordinator;
constructor(
@Inject(TessDurableSessionRepository) repository: TessDurableSessionRepository,
@Inject(DurableSessionRepository) repository: DurableSessionRepository,
@Inject(RuntimeProviderService) private readonly runtimeProviders: RuntimeProviderService,
) {
this.coordinator = new DurableSessionCoordinator(repository);
@@ -32,12 +32,12 @@ export class TessDurableSessionService {
identity.ownerId !== context.actorScope.userId ||
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);
}
async queueProviderSend(input: TessProviderOutboxDto): Promise<void> {
async queueProviderSend(input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(input.sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
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) {
throw new ForbiddenException('Durable Tess outbox session mismatch');
throw new ForbiddenException('Durable outbox session mismatch');
}
const snapshot = await this.coordinator.snapshot(sessionId);
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. */
async recoverProviderSession(sessionId: string, input: TessProviderOutboxDto): Promise<void> {
async recoverProviderSession(sessionId: string, input: ProviderOutboxDto): Promise<void> {
const snapshot = await this.coordinator.snapshot(sessionId);
this.assertScope(snapshot.identity.ownerId, snapshot.identity.tenantId, input);
await this.coordinator.recover(sessionId);
@@ -100,24 +100,24 @@ export class TessDurableSessionService {
private assertOutboxScope(
entry: { kind: string; correlationId: string; channelId: string },
input: TessProviderOutboxDto,
input: ProviderOutboxDto,
): void {
if (
entry.kind !== 'provider.send' ||
entry.correlationId !== input.correlationId ||
entry.channelId !== input.context.channelId
) {
throw new ForbiddenException('Durable Tess outbox scope or correlation mismatch');
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 (
input.context.actorScope.userId !== ownerId ||
input.context.actorScope.tenantId !== tenantId ||
input.context.correlationId !== input.correlationId
) {
throw new ForbiddenException('Durable Tess session scope or correlation mismatch');
throw new ForbiddenException('Durable session scope or correlation mismatch');
}
}
}

View File

@@ -21,8 +21,8 @@ import {
RUNTIME_PROVIDER_AUDIT_SINK,
RuntimeProviderAuditService,
} from './runtime-provider-registry.service.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { TessDurableSessionRepository } from './tess-durable-session.repository.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';
@@ -88,9 +88,9 @@ describe('Hermes runtime provider reachability', (): void => {
.useValue({ record: vi.fn().mockResolvedValue(undefined) })
.overrideProvider(RUNTIME_APPROVAL_VERIFIER)
.useValue({ consume: vi.fn().mockResolvedValue(false) })
.overrideProvider(TessDurableSessionService)
.overrideProvider(DurableSessionService)
.useValue({})
.overrideProvider(TessDurableSessionRepository)
.overrideProvider(DurableSessionRepository)
.useValue({})
.overrideProvider(AgentService)
.useValue({})
@@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): 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);

View File

@@ -17,7 +17,7 @@ import { Observable } from 'rxjs';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { TessDurableSessionService } from './tess-durable-session.service.js';
import { DurableSessionService } from './durable-session.service.js';
import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
import {
RuntimeProviderService,
@@ -34,7 +34,7 @@ import {
export class InteractionController {
constructor(
@Inject(RuntimeProviderService) private readonly runtime: RuntimeProviderService,
@Inject(TessDurableSessionService) private readonly durable: TessDurableSessionService,
@Inject(DurableSessionService) private readonly durable: DurableSessionService,
) {}
@Get('sessions')

View File

@@ -37,7 +37,7 @@ import {
RuntimeProviderService,
type RuntimeAuditSink,
} 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 {
scopeFromUser,
@@ -140,8 +140,8 @@ export class ChatGateway implements OnGatewayInit, OnGatewayConnection, OnGatewa
@Inject(RuntimeProviderService)
private readonly runtimeRegistry: RuntimeProviderService | null = null,
@Optional()
@Inject(TessDurableSessionService)
private readonly durableSessions: TessDurableSessionService | null = null,
@Inject(DurableSessionService)
private readonly durableSessions: DurableSessionService | null = null,
@Optional()
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
private readonly runtimeAudit: RuntimeAuditSink | null = null,

View File

@@ -1,31 +1,32 @@
import { Module } from '@nestjs/common';
import { InMemoryMosCoordinationPort } from '@mosaicstack/coord';
import { InMemoryInteractionCoordinationPort } from '@mosaicstack/coord';
import { CoordService } from './coord.service.js';
import { CoordController } from './coord.controller.js';
import { MosCoordinationController } from './mos-coordination.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
import {
MOS_COORDINATION_CONFIG,
MOS_COORDINATION_PORT,
MosCoordinationService,
} from './mos-coordination.service.js';
COORDINATION_CONFIG,
COORDINATION_PORT,
InteractionCoordinationService,
} from './interaction-coordination.service.js';
@Module({
providers: [
CoordService,
{
provide: MOS_COORDINATION_PORT,
useFactory: (): InMemoryMosCoordinationPort => new InMemoryMosCoordinationPort(),
provide: COORDINATION_PORT,
useFactory: (): InMemoryInteractionCoordinationPort =>
new InMemoryInteractionCoordinationPort(),
},
{
provide: MOS_COORDINATION_CONFIG,
provide: COORDINATION_CONFIG,
useFactory: () => ({
interactionAgentId: process.env['MOSAIC_AGENT_NAME'],
orchestrationAgentId: process.env['MOSAIC_ORCHESTRATOR_AGENT_NAME'],
}),
},
MosCoordinationService,
InteractionCoordinationService,
],
controllers: [CoordController, MosCoordinationController],
exports: [CoordService, MosCoordinationService],
controllers: [CoordController, InteractionCoordinationController],
exports: [CoordService, InteractionCoordinationService],
})
export class CoordModule {}

View File

@@ -1,16 +1,27 @@
const PATH_METADATA = 'path';
import { describe, expect, it, vi } from 'vitest';
import { MosCoordinationController } from './mos-coordination.controller.js';
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
const user = { id: 'operator-1', tenantId: 'tenant-a' };
describe('MosCoordinationController', () => {
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 MosCoordinationController(coordination as never);
const controller = new InteractionCoordinationController(coordination as never);
await controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, 'corr-1');
@@ -26,7 +37,7 @@ describe('MosCoordinationController', () => {
it('requires a correlation header before invoking the coordination service', async () => {
const coordination = { handoff: vi.fn(), observe: vi.fn(), result: vi.fn() };
const controller = new MosCoordinationController(coordination as never);
const controller = new InteractionCoordinationController(coordination as never);
await expect(
controller.handoff({ idempotencyKey: 'request-1', summary: 'Implement' }, user, undefined),

View File

@@ -14,27 +14,29 @@ import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type {
MosCoordinationObservationDto,
MosCoordinationResponseDto,
MosCoordinationResultDto,
CreateMosHandoffDto,
} from './mos-coordination.dto.js';
import { MosCoordinationService } from './mos-coordination.service.js';
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 Mos contract. */
@Controller('api/coord/mos')
/** 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 MosCoordinationController {
export class InteractionCoordinationController {
constructor(
@Inject(MosCoordinationService) private readonly coordination: MosCoordinationService,
@Inject(InteractionCoordinationService)
private readonly coordination: InteractionCoordinationService,
) {}
@Post('handoff')
async handoff(
@Body() request: CreateMosHandoffDto,
@Body() request: CreateHandoffDto,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationResponseDto> {
): Promise<InteractionCoordinationResponseDto> {
return { receipt: await this.coordination.handoff(request, this.context(user, correlationId)) };
}
@@ -43,7 +45,7 @@ export class MosCoordinationController {
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationObservationDto> {
): Promise<InteractionCoordinationObservationDto> {
return {
observation: await this.coordination.observe(handoffId, this.context(user, correlationId)),
};
@@ -54,7 +56,7 @@ export class MosCoordinationController {
@Param('handoffId') handoffId: string,
@CurrentUser() user: AuthenticatedUserLike,
@Headers('x-correlation-id') correlationId?: string,
): Promise<MosCoordinationResultDto> {
): Promise<InteractionCoordinationResultDto> {
return { result: await this.coordination.result(handoffId, this.context(user, correlationId)) };
}

View File

@@ -1,25 +1,25 @@
import type {
CoordinationObservation,
CoordinationResult,
MosHandoffReceipt,
HandoffReceipt,
} from '@mosaicstack/coord';
/** Input accepted at the gateway coordination boundary. Agent identity is not caller-controlled. */
export interface CreateMosHandoffDto {
export interface CreateHandoffDto {
idempotencyKey: string;
summary: string;
context?: string;
missionId?: string;
}
export interface MosCoordinationResponseDto {
receipt: MosHandoffReceipt;
export interface InteractionCoordinationResponseDto {
receipt: HandoffReceipt;
}
export interface MosCoordinationObservationDto {
export interface InteractionCoordinationObservationDto {
observation: CoordinationObservation;
}
export interface MosCoordinationResultDto {
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

@@ -1,15 +1,15 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryMosCoordinationPort,
type MosCoordinationPort,
type MosHandoff,
InMemoryInteractionCoordinationPort,
type InteractionCoordinationPort,
type Handoff,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import {
MosCoordinationService,
type MosCoordinationConfig,
type MosCoordinationGatewayError,
} from './mos-coordination.service.js';
InteractionCoordinationService,
type InteractionCoordinationConfig,
type InteractionCoordinationGatewayError,
} from './interaction-coordination.service.js';
const context: RuntimeProviderRequestContext = {
actorScope: { userId: 'operator-1', tenantId: 'tenant-a' },
@@ -17,28 +17,28 @@ const context: RuntimeProviderRequestContext = {
correlationId: 'corr-1',
};
const config: MosCoordinationConfig = {
const config: InteractionCoordinationConfig = {
interactionAgentId: 'Nova',
orchestrationAgentId: 'Conductor',
};
function service(
port: MosCoordinationPort = new InMemoryMosCoordinationPort(),
port: InteractionCoordinationPort = new InMemoryInteractionCoordinationPort(),
options: {
config?: MosCoordinationConfig;
config?: InteractionCoordinationConfig;
handoffIdFactory?: () => string;
} = {},
): MosCoordinationService {
return new MosCoordinationService(
): InteractionCoordinationService {
return new InteractionCoordinationService(
port,
options.config ?? config,
options.handoffIdFactory ?? (() => 'handoff-1'),
);
}
describe('MosCoordinationService authority boundary', (): void => {
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 InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = service(adapter);
await expect(
@@ -53,8 +53,8 @@ describe('MosCoordinationService authority boundary', (): void => {
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
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({
@@ -64,12 +64,12 @@ describe('MosCoordinationService authority boundary', (): void => {
await expect(coordination.result('handoff-1', followUpContext)).resolves.toMatchObject({
targetAgentId: 'Conductor',
status: 'completed',
summary: 'Merged by Mos',
summary: 'Merged by orchestrator',
});
});
it('fails closed without calling a port when the interaction requester is unconfigured', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: '', orchestrationAgentId: 'Conductor' },
@@ -82,12 +82,12 @@ describe('MosCoordinationService authority boundary', (): void => {
),
).rejects.toMatchObject({
code: 'unconfigured_requester',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(handoff).not.toHaveBeenCalled();
});
it('rejects self-delegation configuration before delivering work', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter, {
config: { interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
@@ -103,7 +103,7 @@ describe('MosCoordinationService authority boundary', (): void => {
});
it('denies cross-tenant observe and result before calling the adapter', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const observe = vi.spyOn(adapter, 'observe');
const result = vi.spyOn(adapter, 'result');
const coordination = service(adapter);
@@ -118,10 +118,10 @@ describe('MosCoordinationService authority boundary', (): void => {
};
await expect(coordination.observe('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(coordination.result('handoff-1', otherTenant)).rejects.toMatchObject({
code: 'cross_tenant_forbidden',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
expect(observe).not.toHaveBeenCalled();
expect(result).not.toHaveBeenCalled();
});
@@ -132,8 +132,8 @@ describe('MosCoordinationService authority boundary', (): void => {
const delivered = new Promise<void>((resolve: () => void): void => {
release = resolve;
});
const adapter: MosCoordinationPort = {
handoff: vi.fn(async (handoff: MosHandoff) => {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => {
await delivered;
return {
handoffId: handoff.handoffId,
@@ -169,7 +169,7 @@ describe('MosCoordinationService authority boundary', (): void => {
});
it('rejects idempotency-key payload drift and malformed handoff input before delivery', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const handoff = vi.spyOn(adapter, 'handoff');
const coordination = service(adapter);
await coordination.handoff(
@@ -181,23 +181,23 @@ describe('MosCoordinationService authority boundary', (): void => {
coordination.handoff({ idempotencyKey: 'request-1', summary: 'Different work' }, context),
).rejects.toMatchObject({
code: 'handoff_conflict',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-2', summary: '' }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<MosCoordinationGatewayError>);
} satisfies Partial<InteractionCoordinationGatewayError>);
await expect(
coordination.handoff({ idempotencyKey: 'request-3', summary: 'x'.repeat(2_049) }, context),
).rejects.toMatchObject({
code: 'invalid_request',
} satisfies Partial<MosCoordinationGatewayError>);
} 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: MosCoordinationPort = {
handoff: vi.fn(async (handoff: MosHandoff) => ({
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async (handoff: Handoff) => ({
handoffId: handoff.handoffId,
targetAgentId: 'Unexpected',
status: 'accepted' as const,

View File

@@ -1,18 +1,18 @@
import { Inject, Injectable } from '@nestjs/common';
import {
MosCoordinationClient,
InteractionCoordinationClient,
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type MosCoordinationIdentity,
type MosCoordinationPort,
type MosHandoffReceipt,
type InteractionCoordinationIdentity,
type InteractionCoordinationPort,
type HandoffReceipt,
} from '@mosaicstack/coord';
import type { RuntimeProviderRequestContext } from '../agent/runtime-provider-registry.service.js';
import type { CreateMosHandoffDto } from './mos-coordination.dto.js';
import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const MOS_COORDINATION_PORT = Symbol('MOS_COORDINATION_PORT');
export const MOS_COORDINATION_CONFIG = Symbol('MOS_COORDINATION_CONFIG');
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;
@@ -21,7 +21,7 @@ const MAX_SUMMARY_LENGTH = 2_048;
const MAX_CONTEXT_LENGTH = 8_192;
const MAX_MISSION_ID_LENGTH = 128;
export interface MosCoordinationConfig {
export interface InteractionCoordinationConfig {
interactionAgentId?: string;
orchestrationAgentId?: string;
}
@@ -34,7 +34,7 @@ interface HandoffOwner {
expiresAt: number;
}
interface NormalizedMosHandoffRequest {
interface NormalizedHandoffRequest {
idempotencyKey: string;
summary: string;
context?: string;
@@ -42,31 +42,31 @@ interface NormalizedMosHandoffRequest {
}
interface TrackedHandoff {
request: NormalizedMosHandoffRequest;
receipt: Promise<MosHandoffReceipt>;
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 Mos-owned orchestration verbs.
* channel request can name a target or gain orchestrator-owned orchestration verbs.
*/
@Injectable()
export class MosCoordinationService {
export class InteractionCoordinationService {
private readonly owners = new Map<string, HandoffOwner>();
private readonly handoffsByIdempotencyKey = new Map<string, TrackedHandoff>();
constructor(
@Inject(MOS_COORDINATION_PORT) private readonly port: MosCoordinationPort,
@Inject(MOS_COORDINATION_CONFIG) private readonly config: MosCoordinationConfig,
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {}
async handoff(
request: CreateMosHandoffDto,
request: CreateHandoffDto,
context: RuntimeProviderRequestContext,
): Promise<MosHandoffReceipt> {
): Promise<HandoffReceipt> {
this.pruneExpiredTracking();
const normalized = this.normalizeRequest(request);
const scope = this.scope(context);
@@ -74,9 +74,9 @@ export class MosCoordinationService {
const existing = this.handoffsByIdempotencyKey.get(idempotencyKey);
if (existing !== undefined) {
if (!sameRequest(existing.request, normalized)) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Mos handoff idempotency key is already bound to different immutable input',
'Handoff idempotency key is already bound to different immutable input',
);
}
return existing.receipt;
@@ -122,9 +122,9 @@ export class MosCoordinationService {
private async deliverHandoff(
handoffId: string,
request: NormalizedMosHandoffRequest,
request: NormalizedHandoffRequest,
scope: CoordinationScope,
): Promise<MosHandoffReceipt> {
): Promise<HandoffReceipt> {
const receipt = await this.client((): string => handoffId).handoff(request, scope);
const owner: HandoffOwner = {
actorId: scope.actorId,
@@ -135,9 +135,9 @@ export class MosCoordinationService {
};
const existing = this.owners.get(receipt.handoffId);
if (existing !== undefined && !sameOwner(existing, owner)) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'handoff_conflict',
'Mos handoff ID is already bound to a different authenticated scope',
'Handoff ID is already bound to a different authenticated scope',
);
}
this.owners.set(receipt.handoffId, owner);
@@ -145,21 +145,21 @@ export class MosCoordinationService {
return receipt;
}
private client(handoffIdFactory?: () => string): MosCoordinationClient {
return new MosCoordinationClient(this.identity(), this.port, handoffIdFactory);
private client(handoffIdFactory?: () => string): InteractionCoordinationClient {
return new InteractionCoordinationClient(this.identity(), this.port, handoffIdFactory);
}
private identity(): MosCoordinationIdentity {
private identity(): InteractionCoordinationIdentity {
const interactionAgentId = this.config.interactionAgentId?.trim();
const orchestrationAgentId = this.config.orchestrationAgentId?.trim();
if (!interactionAgentId) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'unconfigured_requester',
'Interaction agent identity is not configured',
);
}
if (!orchestrationAgentId) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'unconfigured_target',
'Orchestration agent identity is not configured',
);
@@ -177,9 +177,12 @@ export class MosCoordinationService {
});
}
private normalizeRequest(request: CreateMosHandoffDto): NormalizedMosHandoffRequest {
private normalizeRequest(request: CreateHandoffDto): NormalizedHandoffRequest {
if (typeof request !== 'object' || request === null) {
throw new MosCoordinationGatewayError('invalid_request', 'Mos handoff request is invalid');
throw new InteractionCoordinationGatewayError(
'invalid_request',
'Handoff request is invalid',
);
}
const idempotencyKey = this.requiredString(
request.idempotencyKey,
@@ -203,14 +206,17 @@ export class MosCoordinationService {
private requiredString(value: unknown, field: string, maximumLength: number): string {
if (typeof value !== 'string') {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Mos handoff ${field} must be a string`,
`Handoff ${field} must be a string`,
);
}
const normalized = value.trim();
if (normalized.length === 0 || normalized.length > maximumLength) {
throw new MosCoordinationGatewayError('invalid_request', `Mos handoff ${field} is invalid`);
throw new InteractionCoordinationGatewayError(
'invalid_request',
`Handoff ${field} is invalid`,
);
}
return normalized;
}
@@ -245,23 +251,23 @@ export class MosCoordinationService {
private ownerFor(handoffId: string, scope: CoordinationScope): HandoffOwner {
const owner = this.owners.get(handoffId);
if (owner === undefined) {
throw new MosCoordinationGatewayError('not_found', 'Mos handoff was not found');
throw new InteractionCoordinationGatewayError('not_found', 'Handoff was not found');
}
if (
owner.tenantId !== scope.tenantId ||
owner.actorId !== scope.actorId ||
owner.requesterAgentId !== scope.requesterAgentId
) {
throw new MosCoordinationGatewayError(
throw new InteractionCoordinationGatewayError(
'cross_tenant_forbidden',
'Mos handoff is outside the authenticated scope',
'Handoff is outside the authenticated scope',
);
}
return owner;
}
}
export type MosCoordinationGatewayErrorCode =
export type InteractionCoordinationGatewayErrorCode =
| 'cross_tenant_forbidden'
| 'handoff_conflict'
| 'invalid_request'
@@ -277,10 +283,7 @@ function sameOwner(left: HandoffOwner, right: HandoffOwner): boolean {
);
}
function sameRequest(
left: NormalizedMosHandoffRequest,
right: NormalizedMosHandoffRequest,
): boolean {
function sameRequest(left: NormalizedHandoffRequest, right: NormalizedHandoffRequest): boolean {
return (
left.idempotencyKey === right.idempotencyKey &&
left.summary === right.summary &&
@@ -289,12 +292,12 @@ function sameRequest(
);
}
export class MosCoordinationGatewayError extends Error {
export class InteractionCoordinationGatewayError extends Error {
constructor(
readonly code: MosCoordinationGatewayErrorCode,
readonly code: InteractionCoordinationGatewayErrorCode,
message: string,
) {
super(message);
this.name = MosCoordinationGatewayError.name;
this.name = InteractionCoordinationGatewayError.name;
}
}

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)

View File

@@ -124,7 +124,7 @@ paths:
{
summary: Submit Mos handoff,
parameters: [{ $ref: '#/components/parameters/correlation' }],
requestBody: { $ref: '#/components/requestBodies/MosHandoff' },
requestBody: { $ref: '#/components/requestBodies/Handoff' },
responses: { '200': { description: Receipt } },
},
}
@@ -261,7 +261,7 @@ components:
},
},
}
MosHandoff:
Handoff:
{
required: true,
content:

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

@@ -18,12 +18,12 @@ Implement a transport-neutral coordination contract allowing a configured intera
## Design checkpoint — 2026-07-12
Created `docs/tess/MOS-COORDINATION.md`. Mos approved the design and selected the native in-process `InMemoryMosCoordinationPort` for M4. Fleet/tmux remains a documented M5 adapter seam; no Mos-side consumer is built in this task.
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 `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
- Implemented the gateway `MosCoordinationService`, deriving requester identity from trusted configuration and actor/tenant/correlation from authenticated context.
- 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`.

View File

@@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
### Mos Coordination Boundary
`@mosaicstack/coord` exposes only the transport-neutral `MosCoordinationPort`
`@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

View File

@@ -6,7 +6,7 @@ This procedure is evidence-bound. It does not authorize a production cutover unt
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 `MosCoordinationService` for orchestration handoff. Tess does not take Mos authority.
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

@@ -7,5 +7,5 @@ Hermes is a reference adapter, not a Mosaic core dependency. `packages/agent/src
| 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 | `apps/gateway/src/coord/mos-coordination.service.ts` retains authenticated handoff/observe/result ownership checks | 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

@@ -6,5 +6,5 @@ Rollback is configuration/binding reversal, not a database rollback: no Hermes s
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 `MosCoordinationService`; do not create a second orchestrator path.
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

@@ -20,29 +20,29 @@ interface CoordinationScope {
readonly requesterAgentId: string; // trusted gateway/configuration data
}
interface MosHandoffRequest {
interface HandoffRequest {
readonly idempotencyKey: string;
readonly summary: string;
readonly context?: string;
readonly missionId?: string;
}
interface MosHandoffReceipt {
interface HandoffReceipt {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'accepted' | 'queued';
readonly correlationId: string;
}
interface MosHandoff {
interface Handoff {
readonly handoffId: string;
readonly targetAgentId: string;
readonly request: MosHandoffRequest;
readonly request: HandoffRequest;
readonly scope: CoordinationScope;
}
interface MosCoordinationPort {
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
interface InteractionCoordinationPort {
handoff(handoff: Handoff): Promise<HandoffReceipt>;
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
}
@@ -52,22 +52,26 @@ 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 a `MosCoordinationService` boundary that compares the
`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 `InMemoryMosCoordinationPort` as the concrete,
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 `MosCoordinationPort`; no channel client or interaction
implement the same `InteractionCoordinationPort`; no channel client or interaction
runtime calls a transport directly.
## Required tests

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.
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.
- 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.
The original M1 findings below are closed by landed controls and retained for audit traceability.
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.

View File

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

View File

@@ -102,7 +102,7 @@ export interface DurableSessionStore {
export class DurableSessionNotFoundError extends Error {
constructor(sessionId: string) {
super(`Durable Tess session not found: ${sessionId}`);
super(`Durable session not found: ${sessionId}`);
this.name = 'DurableSessionNotFoundError';
}
}
@@ -212,11 +212,11 @@ export class DurableSessionCoordinator {
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
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 checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
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 };
}
@@ -257,7 +257,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const existing = this.sessions.get(identity.sessionId);
if (existing) {
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.runtimeSessionId = identity.runtimeSessionId;
@@ -290,7 +290,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const existing = state.inbox.get(input.idempotencyKey);
if (existing) {
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 };
}
@@ -323,7 +323,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const existing = state.outbox.get(input.idempotencyKey);
if (existing) {
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 };
}
@@ -364,7 +364,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
const checkpoints = this.require(input.sessionId).checkpoints;
const existing = checkpoints.get(input.checkpointId);
if (existing && !sameCheckpoint(existing, input)) {
throw new Error(`Durable Tess checkpoint identity conflict: ${input.checkpointId}`);
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
}
if (!existing) checkpoints.set(input.checkpointId, { ...input });
}
@@ -377,11 +377,11 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
async handoff(input: DurableHandoffInput): Promise<void> {
const state = this.require(input.sessionId);
if (!state.checkpoints.has(input.checkpointId)) {
throw new Error(`Durable Tess handoff checkpoint is unavailable: ${input.checkpointId}`);
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
}
const existing = state.handoffs.get(input.handoffId);
if (existing && !sameHandoff(existing, input)) {
throw new Error(`Durable Tess handoff identity conflict: ${input.handoffId}`);
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
}
if (!existing) state.handoffs.set(input.handoffId, { ...input });
}
@@ -413,7 +413,7 @@ export class InMemoryDurableSessionStore implements DurableSessionStore {
kind: string,
): T {
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;
}
}

View File

@@ -4,4 +4,4 @@ export * from './runtime-provider-registry.js';
export * from './tmux-fleet-runtime-provider.js';
export * from './hermes-runtime-provider.js';
export * from './matrix-native-runtime-provider.js';
export * from './tess-durable-session.js';
export * from './durable-session.js';

View File

@@ -117,7 +117,7 @@ class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
async assertAuthorized(): Promise<void> {
throw new MatrixRuntimeProviderError(
'forbidden',
'Matrix runtime writes require Mos authority',
'Matrix runtime writes require orchestrator authority',
);
}
}

View File

@@ -202,7 +202,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
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();
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
throw new Error('target identity mismatch');
@@ -220,7 +220,20 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
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 writeAuthority: FleetWriteAuthority = {
canWrite: vi.fn(async (): Promise<boolean> => true),
@@ -228,7 +241,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
};
const provider = new TmuxFleetRuntimeProvider({
transport: fleet,
sourceLabel: 'tess',
sourceLabel: 'operator',
writeAuthority,
});
@@ -246,7 +259,7 @@ describe('TmuxFleetRuntimeProvider security policy', (): void => {
scope,
approvalRef: 'approval-1',
});
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'tess');
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
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
* invocation; the default authority prevents direct Tess writes by design.
* invocation; the default authority prevents direct interaction-plane writes by design.
*/
export interface FleetWriteAuthority {
/** Non-consuming preflight used before probing the fleet transport. */
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>;
}
@@ -116,7 +116,7 @@ class DenyFleetWriteAuthority implements FleetWriteAuthority {
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
throw new FleetRuntimeProviderError(
'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
* 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 {
readonly id = FLEET_PROVIDER_ID;
@@ -139,7 +139,7 @@ export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
this.sourceLabel = options.sourceLabel ?? 'tess';
this.sourceLabel = options.sourceLabel ?? 'interaction';
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
this.now = options.now ?? (() => new Date());
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;

View File

@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import {
InMemoryMosCoordinationPort,
MosCoordinationClient,
InMemoryInteractionCoordinationPort,
InteractionCoordinationClient,
type CoordinationScope,
type MosCoordinationAuthorityError,
type MosCoordinationPort,
type InteractionCoordinationAuthorityError,
type InteractionCoordinationPort,
} from '../index.js';
const scope: CoordinationScope = {
@@ -15,19 +15,19 @@ const scope: CoordinationScope = {
};
function client(
port: MosCoordinationPort,
port: InteractionCoordinationPort,
handoffIdFactory: () => string = (): string => 'handoff-1',
): MosCoordinationClient {
return new MosCoordinationClient(
): InteractionCoordinationClient {
return new InteractionCoordinationClient(
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Conductor' },
port,
handoffIdFactory,
);
}
describe('MosCoordinationClient', (): void => {
describe('InteractionCoordinationClient', (): void => {
it('round-trips handoff, observation, and result through the native port with identities as data', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = client(adapter);
await expect(
@@ -42,8 +42,8 @@ describe('MosCoordinationClient', (): void => {
correlationId: 'corr-1',
});
adapter.recordActivity('handoff-1', 'running', 'Mos accepted the request');
adapter.recordResult('handoff-1', 'completed', 'Merged by Mos');
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',
@@ -59,7 +59,7 @@ describe('MosCoordinationClient', (): void => {
targetAgentId: 'Conductor',
status: 'completed',
correlationId: 'corr-1',
summary: 'Merged by Mos',
summary: 'Merged by orchestrator',
});
expect(coordination).not.toHaveProperty('dispatch');
@@ -69,8 +69,8 @@ describe('MosCoordinationClient', (): void => {
expect(coordination).not.toHaveProperty('cancel');
});
it('fails closed before delivery when an unconfigured agent requests Mos work', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort();
it('fails closed before delivery when an unconfigured agent requests orchestrator work', async (): Promise<void> => {
const adapter = new InMemoryInteractionCoordinationPort();
await expect(
client(adapter).handoff(
@@ -79,31 +79,31 @@ describe('MosCoordinationClient', (): void => {
),
).rejects.toMatchObject({
code: 'requester_forbidden',
} satisfies Partial<MosCoordinationAuthorityError>);
} satisfies Partial<InteractionCoordinationAuthorityError>);
});
it('rejects self-delegation configuration before constructing a client', (): void => {
expect(
(): MosCoordinationClient =>
new MosCoordinationClient(
(): InteractionCoordinationClient =>
new InteractionCoordinationClient(
{ interactionAgentId: 'Nova', orchestrationAgentId: 'Nova' },
new InMemoryMosCoordinationPort(),
new InMemoryInteractionCoordinationPort(),
),
).toThrow('Interaction and orchestration identities must differ');
});
it('rejects whitespace-equivalent self-delegation identities', (): void => {
expect(
(): MosCoordinationClient =>
new MosCoordinationClient(
(): InteractionCoordinationClient =>
new InteractionCoordinationClient(
{ interactionAgentId: 'Nova ', orchestrationAgentId: 'Nova' },
new InMemoryMosCoordinationPort(),
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 InMemoryMosCoordinationPort();
const adapter = new InMemoryInteractionCoordinationPort();
const coordination = client(adapter);
await coordination.handoff(
{ idempotencyKey: 'handoff-request-1', summary: 'Implement the requested feature' },
@@ -120,7 +120,7 @@ describe('MosCoordinationClient', (): void => {
});
it('bounds native handoff retention by evicting the oldest handoff', async (): Promise<void> => {
const adapter = new InMemoryMosCoordinationPort({ maxHandoffs: 1 });
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);
@@ -131,7 +131,7 @@ describe('MosCoordinationClient', (): void => {
});
it('fails closed when a transport reports target drift', async (): Promise<void> => {
const adapter: MosCoordinationPort = {
const adapter: InteractionCoordinationPort = {
handoff: vi.fn(async () => ({
handoffId: 'handoff-1',
targetAgentId: 'Unexpected',
@@ -149,6 +149,6 @@ describe('MosCoordinationClient', (): void => {
),
).rejects.toMatchObject({
code: 'target_drift',
} satisfies Partial<MosCoordinationAuthorityError>);
} satisfies Partial<InteractionCoordinationAuthorityError>);
});
});

View File

@@ -2,25 +2,25 @@ import {
type CoordinationObservation,
type CoordinationResult,
type CoordinationScope,
type MosCoordinationActivity,
type MosCoordinationPort,
type MosHandoff,
type MosHandoffReceipt,
type MosHandoffStatus,
} from './mos-coordination.js';
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: MosHandoff;
status: MosHandoffStatus;
readonly activity: MosCoordinationActivity[];
readonly handoff: Handoff;
status: HandoffStatus;
readonly activity: InteractionCoordinationActivity[];
readonly expiresAt: number;
result?: CoordinationResult;
}
export interface InMemoryMosCoordinationPortOptions {
export interface InMemoryInteractionCoordinationPortOptions {
now?: () => Date;
handoffTtlMs?: number;
maxHandoffs?: number;
@@ -29,21 +29,21 @@ export interface InMemoryMosCoordinationPortOptions {
/**
* Native deterministic queue/port adapter for the coordination boundary.
* It intentionally has no fleet/tmux dependency. A future deployment adapter
* implements MosCoordinationPort without changing interaction-plane callers.
* implements InteractionCoordinationPort without changing interaction-plane callers.
*/
export class InMemoryMosCoordinationPort implements MosCoordinationPort {
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: InMemoryMosCoordinationPortOptions = {}) {
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: MosHandoff): Promise<MosHandoffReceipt> {
async handoff(handoff: Handoff): Promise<HandoffReceipt> {
this.pruneExpiredHandoffs();
const existing = this.handoffs.get(handoff.handoffId);
if (existing !== undefined) {
@@ -88,14 +88,14 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
}
/** Host-side progression seam; interaction clients never receive this capability. */
recordActivity(handoffId: string, status: MosHandoffStatus, summary: string): void {
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 a Mos consumer. */
/** 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);
@@ -110,7 +110,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
};
}
private receipt(handoff: MosHandoff, status: MosHandoffStatus): MosHandoffReceipt {
private receipt(handoff: Handoff, status: HandoffStatus): HandoffReceipt {
return {
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,
@@ -141,7 +141,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
stored.handoff.scope.actorId !== scope.actorId ||
stored.handoff.scope.requesterAgentId !== scope.requesterAgentId
) {
throw new InMemoryMosCoordinationError('forbidden', 'Handoff scope does not match');
throw new InMemoryInteractionCoordinationError('forbidden', 'Handoff scope does not match');
}
return stored;
}
@@ -149,12 +149,12 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
private requireHandoff(handoffId: string): StoredHandoff {
const stored = this.handoffs.get(handoffId);
if (stored === undefined) {
throw new InMemoryMosCoordinationError('not_found', 'Handoff was not found');
throw new InMemoryInteractionCoordinationError('not_found', 'Handoff was not found');
}
return stored;
}
private assertSameHandoff(existing: MosHandoff, incoming: MosHandoff): void {
private assertSameHandoff(existing: Handoff, incoming: Handoff): void {
if (
existing.targetAgentId !== incoming.targetAgentId ||
existing.request.idempotencyKey !== incoming.request.idempotencyKey ||
@@ -166,7 +166,7 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
existing.scope.correlationId !== incoming.scope.correlationId ||
existing.scope.requesterAgentId !== incoming.scope.requesterAgentId
) {
throw new InMemoryMosCoordinationError(
throw new InMemoryInteractionCoordinationError(
'conflict',
'Handoff ID is already bound to different immutable input',
);
@@ -174,31 +174,31 @@ export class InMemoryMosCoordinationPort implements MosCoordinationPort {
}
}
export type InMemoryMosCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
export type InMemoryInteractionCoordinationErrorCode = 'conflict' | 'forbidden' | 'not_found';
export class InMemoryMosCoordinationError extends Error {
export class InMemoryInteractionCoordinationError extends Error {
constructor(
readonly code: InMemoryMosCoordinationErrorCode,
readonly code: InMemoryInteractionCoordinationErrorCode,
message: string,
) {
super(message);
this.name = InMemoryMosCoordinationError.name;
this.name = InMemoryInteractionCoordinationError.name;
}
}
function activity(
status: MosHandoffStatus,
status: HandoffStatus,
summary: string,
now: () => Date,
): MosCoordinationActivity {
): InteractionCoordinationActivity {
return { occurredAt: now().toISOString(), status, summary };
}
function copyActivity(entry: MosCoordinationActivity): MosCoordinationActivity {
function copyActivity(entry: InteractionCoordinationActivity): InteractionCoordinationActivity {
return { ...entry };
}
function snapshotHandoff(handoff: MosHandoff): MosHandoff {
function snapshotHandoff(handoff: Handoff): Handoff {
return Object.freeze({
handoffId: handoff.handoffId,
targetAgentId: handoff.targetAgentId,

View File

@@ -3,22 +3,25 @@ export { parseTasksFile, updateTaskStatus, writeTasksFile } from './tasks-file.j
export { runTask, resumeTask } from './runner.js';
export { getMissionStatus, getTaskStatus } from './status.js';
export {
InMemoryMosCoordinationError,
InMemoryMosCoordinationPort,
} from './in-memory-mos-coordination-port.js';
export { MosCoordinationAuthorityError, MosCoordinationClient } from './mos-coordination.js';
InMemoryInteractionCoordinationError,
InMemoryInteractionCoordinationPort,
} from './in-memory-interaction-coordination-port.js';
export {
InteractionCoordinationAuthorityError,
InteractionCoordinationClient,
} from './interaction-coordination.js';
export type {
CoordinationObservation,
CoordinationResult,
CoordinationScope,
MosCoordinationActivity,
MosCoordinationIdentity,
MosCoordinationPort,
MosHandoff,
MosHandoffReceipt,
MosHandoffRequest,
MosHandoffStatus,
} from './mos-coordination.js';
InteractionCoordinationActivity,
InteractionCoordinationIdentity,
InteractionCoordinationPort,
Handoff,
HandoffReceipt,
HandoffRequest,
HandoffStatus,
} from './interaction-coordination.js';
export type {
CreateMissionOptions,
Mission,

View File

@@ -1,4 +1,4 @@
export type MosHandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
export type HandoffStatus = 'queued' | 'accepted' | 'running' | 'completed' | 'failed';
export interface CoordinationScope {
readonly actorId: string;
@@ -8,44 +8,44 @@ export interface CoordinationScope {
readonly requesterAgentId: string;
}
export interface MosCoordinationIdentity {
export interface InteractionCoordinationIdentity {
readonly interactionAgentId: string;
readonly orchestrationAgentId: string;
}
export interface MosHandoffRequest {
export interface HandoffRequest {
readonly idempotencyKey: string;
readonly summary: string;
readonly context?: string;
readonly missionId?: string;
}
export interface MosHandoff {
export interface Handoff {
readonly handoffId: string;
readonly targetAgentId: string;
readonly request: MosHandoffRequest;
readonly request: HandoffRequest;
readonly scope: CoordinationScope;
}
export interface MosHandoffReceipt {
export interface HandoffReceipt {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: 'queued' | 'accepted';
readonly correlationId: string;
}
export interface MosCoordinationActivity {
export interface InteractionCoordinationActivity {
readonly occurredAt: string;
readonly status: MosHandoffStatus;
readonly status: HandoffStatus;
readonly summary: string;
}
export interface CoordinationObservation {
readonly handoffId: string;
readonly targetAgentId: string;
readonly status: MosHandoffStatus;
readonly status: HandoffStatus;
readonly correlationId: string;
readonly activity: readonly MosCoordinationActivity[];
readonly activity: readonly InteractionCoordinationActivity[];
}
export interface CoordinationResult {
@@ -61,25 +61,25 @@ export interface CoordinationResult {
* its progress/result, but it cannot issue worker, review, merge, or other
* general orchestration commands.
*/
export interface MosCoordinationPort {
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
export interface InteractionCoordinationPort {
handoff(handoff: Handoff): Promise<HandoffReceipt>;
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
}
export type MosCoordinationAuthorityErrorCode =
export type InteractionCoordinationAuthorityErrorCode =
| 'invalid_identity'
| 'requester_forbidden'
| 'target_drift'
| 'correlation_drift';
export class MosCoordinationAuthorityError extends Error {
export class InteractionCoordinationAuthorityError extends Error {
constructor(
readonly code: MosCoordinationAuthorityErrorCode,
readonly code: InteractionCoordinationAuthorityErrorCode,
message: string,
) {
super(message);
this.name = MosCoordinationAuthorityError.name;
this.name = InteractionCoordinationAuthorityError.name;
}
}
@@ -87,20 +87,20 @@ export class MosCoordinationAuthorityError extends Error {
* Enforces the interaction-to-orchestration authority boundary before a
* transport is reached. Identity names remain configuration data.
*/
export class MosCoordinationClient {
private readonly identity: MosCoordinationIdentity;
export class InteractionCoordinationClient {
private readonly identity: InteractionCoordinationIdentity;
constructor(
identity: MosCoordinationIdentity,
private readonly port: MosCoordinationPort,
identity: InteractionCoordinationIdentity,
private readonly port: InteractionCoordinationPort,
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {
this.identity = normalizeIdentity(identity);
}
async handoff(request: MosHandoffRequest, scope: CoordinationScope): Promise<MosHandoffReceipt> {
async handoff(request: HandoffRequest, scope: CoordinationScope): Promise<HandoffReceipt> {
this.assertRequester(scope);
const handoff: MosHandoff = {
const handoff: Handoff = {
handoffId: this.handoffIdFactory(),
targetAgentId: this.identity.orchestrationAgentId,
request: snapshotRequest(request),
@@ -111,15 +111,15 @@ export class MosCoordinationClient {
receipt.handoffId !== handoff.handoffId ||
receipt.targetAgentId !== handoff.targetAgentId
) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Mos coordination transport returned a mismatched handoff target',
'Interaction coordination transport returned a mismatched handoff target',
);
}
if (receipt.correlationId !== handoff.scope.correlationId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Mos coordination transport returned a mismatched correlation ID',
'Interaction coordination transport returned a mismatched correlation ID',
);
}
return receipt;
@@ -137,7 +137,7 @@ export class MosCoordinationClient {
private assertRequester(scope: CoordinationScope): void {
if (scope.requesterAgentId !== this.identity.interactionAgentId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'requester_forbidden',
'Requester is not the configured interaction agent',
);
@@ -149,15 +149,15 @@ export class MosCoordinationClient {
scope: CoordinationScope,
): CoordinationObservation {
if (observation.targetAgentId !== this.identity.orchestrationAgentId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Mos coordination transport returned an unexpected observation target',
'Interaction coordination transport returned an unexpected observation target',
);
}
if (observation.correlationId !== scope.correlationId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Mos coordination transport returned a mismatched observation correlation ID',
'Interaction coordination transport returned a mismatched observation correlation ID',
);
}
return observation;
@@ -165,32 +165,34 @@ export class MosCoordinationClient {
private assertResult(result: CoordinationResult, scope: CoordinationScope): CoordinationResult {
if (result.targetAgentId !== this.identity.orchestrationAgentId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'target_drift',
'Mos coordination transport returned an unexpected result target',
'Interaction coordination transport returned an unexpected result target',
);
}
if (result.correlationId !== scope.correlationId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'correlation_drift',
'Mos coordination transport returned a mismatched result correlation ID',
'Interaction coordination transport returned a mismatched result correlation ID',
);
}
return result;
}
}
function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationIdentity {
function normalizeIdentity(
identity: InteractionCoordinationIdentity,
): InteractionCoordinationIdentity {
const interactionAgentId = identity.interactionAgentId.trim();
const orchestrationAgentId = identity.orchestrationAgentId.trim();
if (interactionAgentId.length === 0 || orchestrationAgentId.length === 0) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'invalid_identity',
'Interaction and orchestration identities are required',
);
}
if (interactionAgentId === orchestrationAgentId) {
throw new MosCoordinationAuthorityError(
throw new InteractionCoordinationAuthorityError(
'invalid_identity',
'Interaction and orchestration identities must differ',
);
@@ -198,7 +200,7 @@ function normalizeIdentity(identity: MosCoordinationIdentity): MosCoordinationId
return Object.freeze({ interactionAgentId, orchestrationAgentId });
}
function snapshotRequest(request: MosHandoffRequest): MosHandoffRequest {
function snapshotRequest(request: HandoffRequest): HandoffRequest {
return Object.freeze({ ...request });
}

View File

@@ -2,10 +2,10 @@
The **operator-interaction** role is the authorized human interaction plane for
Mosaic. It presents runtime and fleet state, mediates approved actions, and
hands coding or general orchestration work to Mos.
hands coding or general orchestration work to the orchestrator.
## 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 does not receive or surface credentials in its effective policy.

View File

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

View File

@@ -189,6 +189,36 @@ describe('fleet roster parsing', () => {
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)', () => {
expect(socketArgs('mosaic-fleet')).toEqual(['-L', 'mosaic-fleet']);
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 () => {
const home = await mkdtemp(join(tmpdir(), 'mosaic-fleet-'));
const rosterPath = join(home, 'fleet', 'roster.yaml');

View File

@@ -81,6 +81,8 @@ interface RawFleetRoster {
runtimes?: Record<string, { reset_command?: unknown; resetCommand?: unknown }>;
agents?: Array<{
name?: unknown;
alias?: unknown;
provider?: unknown;
runtime?: unknown;
class?: unknown;
working_directory?: unknown;
@@ -102,6 +104,8 @@ interface RawFleetRoster {
export interface FleetAgent {
name: string;
alias?: string;
provider?: string;
runtime: string;
className: string;
workingDirectory?: string;
@@ -1074,6 +1078,7 @@ export interface HeartbeatInfo {
export interface AgentPsRow {
name: string;
alias?: string;
tenant_id: string;
host: string;
runtime: string;
@@ -1755,6 +1760,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
rows.push({
name: agent.name,
alias: agent.alias,
tenant_id,
host,
runtime: agent.runtime,
@@ -1888,7 +1894,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
console.log(
[
row.name.padEnd(18),
(row.alias ?? row.name).padEnd(18),
row.tenant_id.padEnd(12),
row.host.padEnd(12),
row.runtime.padEnd(10),
@@ -2494,6 +2500,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
assertObject(raw, 'Fleet roster agent');
assertKnownKeys(raw, 'Fleet roster agent', [
'name',
'alias',
'provider',
'runtime',
'class',
'working_directory',
@@ -2525,6 +2533,8 @@ function normalizeAgent(raw: NonNullable<RawFleetRoster['agents']>[number]): Fle
}
return {
name,
alias: optionalString(raw.alias, `Fleet roster agent "${name}" alias`),
provider: optionalString(raw.provider, `Fleet roster agent "${name}" provider`),
runtime,
className: stringValue(raw.class, 'worker', `Fleet roster agent "${name}" class`),
workingDirectory: optionalString(
@@ -2827,6 +2837,12 @@ export function serializeRosterToYaml(roster: FleetRoster): string {
runtime: agent.runtime,
class: agent.className,
};
if (agent.alias !== undefined) {
raw['alias'] = agent.alias;
}
if (agent.provider !== undefined) {
raw['provider'] = agent.provider;
}
if (agent.workingDirectory !== undefined) {
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.',
'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');
}

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

@@ -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