Compare commits
5 Commits
f6d6d0bb62
...
mos-comms
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b1ad5ca82 | ||
| d077183554 | |||
| 405984af5a | |||
| 8dd4e9d541 | |||
| bc8016c831 |
@@ -115,6 +115,38 @@ describe('Hermes runtime provider reachability', (): void => {
|
|||||||
await app?.close();
|
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> => {
|
it('requires authentication and reaches the Hermes provider registered by AgentModule', async (): Promise<void> => {
|
||||||
if (!app) throw new Error('Nest application did not initialize');
|
if (!app) throw new Error('Nest application did not initialize');
|
||||||
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
const registry = app.get(AGENT_RUNTIME_PROVIDER_REGISTRY);
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
|
const PATH_METADATA = 'path';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
import { InteractionCoordinationController } from './interaction-coordination.controller.js';
|
||||||
|
|
||||||
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
const user = { id: 'operator-1', tenantId: 'tenant-a' };
|
||||||
|
|
||||||
describe('InteractionCoordinationController', () => {
|
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 () => {
|
it('derives actor and tenant from the authenticated user rather than handoff input', async () => {
|
||||||
const coordination = {
|
const coordination = {
|
||||||
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
handoff: vi.fn(async () => ({ handoffId: 'handoff-1' })),
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import type {
|
|||||||
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
import { InteractionCoordinationService } from './interaction-coordination.service.js';
|
||||||
|
|
||||||
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
/** Authenticated interaction-plane boundary for the handoff/observe/result-only interaction coordination contract. */
|
||||||
@Controller('api/coord/mos')
|
/** `api/coord/interaction` is canonical; the Mos path remains a compatibility alias. */
|
||||||
|
@Controller(['api/coord/interaction', 'api/coord/mos'])
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
export class InteractionCoordinationController {
|
export class InteractionCoordinationController {
|
||||||
constructor(
|
constructor(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
docs/SITEMAP.md
Normal file
32
docs/SITEMAP.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Documentation Sitemap
|
||||||
|
|
||||||
|
## Tess interaction agent
|
||||||
|
|
||||||
|
### Operator guides
|
||||||
|
|
||||||
|
- [User guide](tess/USER-GUIDE.md) — authorized session, attach, send, stop, and handoff workflows.
|
||||||
|
- [Admin guide](tess/ADMIN-GUIDE.md) — deployment configuration, policy, and approval controls.
|
||||||
|
- [Developer guide](tess/DEVELOPER-GUIDE.md) — provider contracts, scope boundaries, and test workflow.
|
||||||
|
- [Plugin guide](tess/PLUGIN-GUIDE.md) — adapter, redaction, and identity-as-data requirements.
|
||||||
|
- [Operations guide](tess/OPERATIONS-GUIDE.md) — readiness, recovery, and incident-safe procedures.
|
||||||
|
|
||||||
|
### Architecture and security
|
||||||
|
|
||||||
|
- [Architecture](tess/ARCHITECTURE.md)
|
||||||
|
- [Threat model](tess/THREAT-MODEL.md)
|
||||||
|
- [Mos coordination boundary](tess/MOS-COORDINATION.md)
|
||||||
|
- [Hermes runtime adapter design](tess/hermes-runtime-adapter-design.md)
|
||||||
|
- [Operator plugin sketch](tess/M4-003-OPERATOR-PLUGIN-SKETCH.md)
|
||||||
|
|
||||||
|
### API contract
|
||||||
|
|
||||||
|
- [Tess OpenAPI contract](openapi-tess.yaml)
|
||||||
|
|
||||||
|
### Migration and qualification
|
||||||
|
|
||||||
|
- [Migration inventory](tess/M5-MIGRATION-INVENTORY.md)
|
||||||
|
- [Cutover procedure](tess/M5-MIGRATION-CUTOVER.md)
|
||||||
|
- [Rollback procedure](tess/M5-MIGRATION-ROLLBACK.md)
|
||||||
|
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
|
||||||
|
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
|
||||||
|
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
|
||||||
389
docs/openapi-tess.yaml
Normal file
389
docs/openapi-tess.yaml
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Mosaic Tess Gateway, version: 1.0.0 }
|
||||||
|
security: [{ sessionAuth: [] }]
|
||||||
|
paths:
|
||||||
|
/api/interaction/{agentName}/sessions:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: List authorized runtime sessions,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/provider' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: Sessions } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/transitional-capabilities:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Get transitional capability matrix,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/provider' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: Matrix } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/tree:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Get authorized session tree,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/provider' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: Tree } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/sessions/{sessionId}/enroll:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Enroll a durable session,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/sessionId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Enroll' },
|
||||||
|
responses: { '200': { description: Enrolled } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/sessions/{sessionId}/attach:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Attach to a runtime session,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/sessionId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Attach' },
|
||||||
|
responses: { '200': { description: Attachment } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/sessions/{sessionId}/send:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Queue a durable provider send,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/sessionId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Send' },
|
||||||
|
responses: { '200': { description: Queued } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/sessions/{sessionId}/stop:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Stop a session with approval,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/sessionId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Stop' },
|
||||||
|
responses: { '200': { description: Stopped }, '403': { description: Approval denied } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/sessions/{sessionId}/recover:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Recover interrupted durable work,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/sessionId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: Recovered } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/coord/mos/handoff:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Submit Mos handoff,
|
||||||
|
parameters: [{ $ref: '#/components/parameters/correlation' }],
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Handoff' },
|
||||||
|
responses: { '200': { description: Receipt } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/coord/mos/{handoffId}/observe:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Observe Mos handoff,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/handoffId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: Observation } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/coord/mos/{handoffId}/result:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Get Mos handoff result,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/handoffId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses: { '200': { description: Result } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/interaction/{agentName}/sessions/{sessionId}/stream:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Stream runtime events,
|
||||||
|
parameters:
|
||||||
|
[
|
||||||
|
{ $ref: '#/components/parameters/agentName' },
|
||||||
|
{ $ref: '#/components/parameters/sessionId' },
|
||||||
|
{ $ref: '#/components/parameters/correlation' },
|
||||||
|
],
|
||||||
|
responses:
|
||||||
|
{
|
||||||
|
'200':
|
||||||
|
{
|
||||||
|
description: Event stream,
|
||||||
|
content: { text/event-stream: { schema: { type: string } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/memory/preferences:
|
||||||
|
{
|
||||||
|
get: { summary: List preferences, responses: { '200': { description: Preferences } } },
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Upsert preference,
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Preference' },
|
||||||
|
responses: { '200': { description: Preference } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/memory/preferences/{key}:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Get preference,
|
||||||
|
parameters: [{ $ref: '#/components/parameters/key' }],
|
||||||
|
responses: { '200': { description: Preference } },
|
||||||
|
},
|
||||||
|
delete:
|
||||||
|
{
|
||||||
|
summary: Delete preference,
|
||||||
|
parameters: [{ $ref: '#/components/parameters/key' }],
|
||||||
|
responses: { '204': { description: Deleted } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/memory/insights:
|
||||||
|
{
|
||||||
|
get: { summary: List insights, responses: { '200': { description: Insights } } },
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Create insight,
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Insight' },
|
||||||
|
responses: { '200': { description: Insight } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/memory/insights/{id}:
|
||||||
|
{
|
||||||
|
get:
|
||||||
|
{
|
||||||
|
summary: Get insight,
|
||||||
|
parameters: [{ $ref: '#/components/parameters/id' }],
|
||||||
|
responses: { '200': { description: Insight } },
|
||||||
|
},
|
||||||
|
delete:
|
||||||
|
{
|
||||||
|
summary: Delete insight,
|
||||||
|
parameters: [{ $ref: '#/components/parameters/id' }],
|
||||||
|
responses: { '204': { description: Deleted } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
/api/memory/search:
|
||||||
|
{
|
||||||
|
post:
|
||||||
|
{
|
||||||
|
summary: Search memory,
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Search' },
|
||||||
|
responses: { '200': { description: Search results } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
components:
|
||||||
|
securitySchemes: { sessionAuth: { type: http, scheme: bearer } }
|
||||||
|
parameters:
|
||||||
|
agentName: { name: agentName, in: path, required: true, schema: { type: string } }
|
||||||
|
sessionId: { name: sessionId, in: path, required: true, schema: { type: string } }
|
||||||
|
provider: { name: provider, in: query, required: true, schema: { type: string } }
|
||||||
|
correlation: { name: X-Correlation-Id, in: header, required: true, schema: { type: string } }
|
||||||
|
key: { name: key, in: path, required: true, schema: { type: string } }
|
||||||
|
id: { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBodies:
|
||||||
|
Enroll:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [providerId, runtimeSessionId],
|
||||||
|
properties:
|
||||||
|
{ providerId: { type: string }, runtimeSessionId: { type: string } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Handoff:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [idempotencyKey, summary],
|
||||||
|
properties:
|
||||||
|
{
|
||||||
|
idempotencyKey: { type: string },
|
||||||
|
summary: { type: string },
|
||||||
|
context: { type: string },
|
||||||
|
missionId: { type: string },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Attach:
|
||||||
|
{
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json: { schema: { type: object, properties: { mode: { enum: [read] } } } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Send:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [content, idempotencyKey],
|
||||||
|
properties: { content: { type: string }, idempotencyKey: { type: string } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Stop:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [approvalRef],
|
||||||
|
properties: { approvalRef: { type: string } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Preference:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [key, value],
|
||||||
|
properties:
|
||||||
|
{
|
||||||
|
key: { type: string },
|
||||||
|
value: {},
|
||||||
|
category: { type: string },
|
||||||
|
source: { type: string },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Insight:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [content],
|
||||||
|
properties:
|
||||||
|
{
|
||||||
|
content: { type: string },
|
||||||
|
source: { type: string },
|
||||||
|
category: { type: string },
|
||||||
|
metadata: { type: object },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Search:
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
content:
|
||||||
|
{
|
||||||
|
application/json:
|
||||||
|
{
|
||||||
|
schema:
|
||||||
|
{
|
||||||
|
type: object,
|
||||||
|
required: [query],
|
||||||
|
properties:
|
||||||
|
{
|
||||||
|
query: { type: string },
|
||||||
|
limit: { type: integer },
|
||||||
|
maxDistance: { type: number },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -18,12 +18,12 @@ Implement a transport-neutral coordination contract allowing a configured intera
|
|||||||
|
|
||||||
## Design checkpoint — 2026-07-12
|
## 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
|
## Progress checkpoint — 2026-07-13
|
||||||
|
|
||||||
- Implemented `MosCoordinationPort` with handoff/observe/result only, an authority-checking client, and deterministic native adapter in `@mosaicstack/coord`.
|
- Implemented `InteractionCoordinationPort` 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 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.
|
- 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`.
|
- Did not modify `apps/gateway/src/commands/command-authorization.service.ts`.
|
||||||
|
|
||||||
|
|||||||
5
docs/tess/ADMIN-GUIDE.md
Normal file
5
docs/tess/ADMIN-GUIDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Tess Administration
|
||||||
|
|
||||||
|
Configure agent/provider identities outside client input. Verify `/health/ready` and provider health before enabling interaction clients. Every interaction request requires an authenticated actor and correlation header; tenant and owner scope are server-derived. Do not log or return service credentials.
|
||||||
|
|
||||||
|
For an incident, preserve correlation IDs, inspect provider status and durable checkpoint/inbox/outbox state, then use the recovery endpoint. Do not retry an ambiguous external effect automatically. Stop operations require an exact one-time approval reference; provisioning or granting a broad admin capability does not replace that check.
|
||||||
@@ -54,7 +54,7 @@ Termination is fail-closed: a runtime approval verifier consumes a one-time, exa
|
|||||||
|
|
||||||
### Mos Coordination Boundary
|
### 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,
|
verbs `handoff`, `observe`, and `result`. Gateway derives the actor, tenant,
|
||||||
correlation, and interaction-agent identity from authenticated context plus
|
correlation, and interaction-agent identity from authenticated context plus
|
||||||
trusted configuration; callers never provide an orchestration target. It
|
trusted configuration; callers never provide an orchestration target. It
|
||||||
|
|||||||
3
docs/tess/DEVELOPER-GUIDE.md
Normal file
3
docs/tess/DEVELOPER-GUIDE.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Tess Developer Guide
|
||||||
|
|
||||||
|
Interaction adapters pass only server-derived actor/tenant scope, channel, and correlation to runtime providers. Durable session state owns inbox/outbox/checkpoint recovery. Use the OpenAPI contract rather than inventing routes; unsupported provider capabilities fail closed.
|
||||||
8
docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md
Normal file
8
docs/tess/M5-003-DOCUMENTATION-CHECKLIST.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# TESS-M5-003 Documentation Checklist
|
||||||
|
|
||||||
|
- [x] `openapi-tess.yaml`: authenticated interaction endpoints including SSE stream, Mos handoff/observe/result, and memory preferences, insights, and search.
|
||||||
|
- [x] User guide: authorized session and handoff workflows.
|
||||||
|
- [x] Admin guide: provisioning, policy, health, and approval boundary.
|
||||||
|
- [x] Developer guide: scope, durable state, and provider adapter contract.
|
||||||
|
- [x] Plugin guide: replaceable-adapter, redaction, and identity-as-data rules.
|
||||||
|
- [x] Operations guide: readiness, recovery, ambiguous-effect safety, and tracing.
|
||||||
@@ -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.
|
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.
|
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.
|
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.
|
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.
|
No claim here authorizes bulk transcript copying, data-schema migration, or enabling an unsupported transitional capability.
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| transcripts, profiles, preferences | no Hermes importer/schema mapping landed | no automatic migration |
|
||||||
|
|||||||
@@ -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`).
|
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.
|
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.
|
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.
|
6. Capture the binding reversal, affected scope, correlation IDs, and reason in the approved operational record before retrying a cutover.
|
||||||
|
|||||||
@@ -20,29 +20,29 @@ interface CoordinationScope {
|
|||||||
readonly requesterAgentId: string; // trusted gateway/configuration data
|
readonly requesterAgentId: string; // trusted gateway/configuration data
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosHandoffRequest {
|
interface HandoffRequest {
|
||||||
readonly idempotencyKey: string;
|
readonly idempotencyKey: string;
|
||||||
readonly summary: string;
|
readonly summary: string;
|
||||||
readonly context?: string;
|
readonly context?: string;
|
||||||
readonly missionId?: string;
|
readonly missionId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosHandoffReceipt {
|
interface HandoffReceipt {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly status: 'accepted' | 'queued';
|
readonly status: 'accepted' | 'queued';
|
||||||
readonly correlationId: string;
|
readonly correlationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosHandoff {
|
interface Handoff {
|
||||||
readonly handoffId: string;
|
readonly handoffId: string;
|
||||||
readonly targetAgentId: string;
|
readonly targetAgentId: string;
|
||||||
readonly request: MosHandoffRequest;
|
readonly request: HandoffRequest;
|
||||||
readonly scope: CoordinationScope;
|
readonly scope: CoordinationScope;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MosCoordinationPort {
|
interface InteractionCoordinationPort {
|
||||||
handoff(handoff: MosHandoff): Promise<MosHandoffReceipt>;
|
handoff(handoff: Handoff): Promise<HandoffReceipt>;
|
||||||
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
observe(handoffId: string, scope: CoordinationScope): Promise<CoordinationObservation>;
|
||||||
result(handoffId: string, scope: CoordinationScope): Promise<CoordinationResult>;
|
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`,
|
correlation-scoped; its gateway implementation obtains `actorId`, `tenantId`,
|
||||||
and the requester agent from trusted authentication/configuration only.
|
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
|
## 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
|
trusted configured requester/target identities and rejects all of the following
|
||||||
before calling a transport: unconfigured requester, self-delegation, target
|
before calling a transport: unconfigured requester, self-delegation, target
|
||||||
identity drift, cross-tenant observe/result lookup, and attempts to observe or
|
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,
|
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.
|
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
|
deterministic adapter. It preserves the immutable handoff ID, tenant, requester
|
||||||
identity, and correlation ID while demonstrating the handoff → observe → result
|
identity, and correlation ID while demonstrating the handoff → observe → result
|
||||||
round trip. It is a queue/port adapter, not a Mos-side consumer.
|
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
|
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.
|
runtime calls a transport directly.
|
||||||
|
|
||||||
## Required tests
|
## Required tests
|
||||||
|
|||||||
3
docs/tess/OPERATIONS-GUIDE.md
Normal file
3
docs/tess/OPERATIONS-GUIDE.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Tess Operations and Recovery
|
||||||
|
|
||||||
|
Check `/health/ready`, provider health, and effective policy before recovery. Recover durable sessions through the interaction recovery operation; it requeues only interrupted work and does not replay ambiguous external effects. Preserve correlation IDs for incident tracing and use Mos handoff observation/result endpoints for orchestration visibility.
|
||||||
3
docs/tess/PLUGIN-GUIDE.md
Normal file
3
docs/tess/PLUGIN-GUIDE.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Tess Plugin Authoring
|
||||||
|
|
||||||
|
Plugins are replaceable adapters. Declare capabilities, derive scope from trusted context, preserve correlation IDs, redact before persistence/egress, and return unsupported operations as fail-closed results. Names and identities are configuration data, not literals in keys or defaults.
|
||||||
File diff suppressed because one or more lines are too long
@@ -33,14 +33,18 @@ Trust boundaries: Discord→plugin, CLI→gateway, plugin→gateway service iden
|
|||||||
6. Every externally caused operation is replay-safe and correlated.
|
6. Every externally caused operation is replay-safe and correlated.
|
||||||
7. Provider capability absence is a denial, not an invitation to shell around it.
|
7. Provider capability absence is a denial, not an invitation to shell around it.
|
||||||
|
|
||||||
## Existing Findings That Block Tess
|
## Closed Prerequisite Findings
|
||||||
|
|
||||||
- Command executor lacks server-side enforcement for declared scopes.
|
The original M1 findings below are closed by landed controls and retained for audit traceability.
|
||||||
- Session list/reuse/destroy surfaces are not owner-filtered consistently.
|
|
||||||
- MCP schemas accept caller-supplied user identity.
|
|
||||||
- Discord plugin lacks a complete authenticated service ingress and user/channel allowlists.
|
|
||||||
- Chat/tool persistence lacks mandatory redaction.
|
|
||||||
- Sessions/pending Discord output are in-memory and not restart-safe.
|
|
||||||
- Session GC currently performs globally scoped promotion.
|
|
||||||
|
|
||||||
These are tracked as M1 security prerequisites and must pass independent security review before Tess ingress is enabled.
|
| Former finding | Closed evidence |
|
||||||
|
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Command scope/role enforcement | `apps/gateway/src/commands/command-authorization.service.ts` and its authorization tests enforce the server-side approval boundary. |
|
||||||
|
| Cross-owner session access | Gateway session ownership tests cover server-derived owner and tenant scope. |
|
||||||
|
| Caller-controlled MCP identity | MCP tools derive actor and tenant from authenticated gateway context. |
|
||||||
|
| Missing Discord ingress allowlists | `apps/gateway/src/plugin/plugin.module.ts` requires the guild, channel, and user allowlist environment values; `apps/gateway/src/plugin/discord-ingress.security.spec.ts` exercises denial and configured ingress. |
|
||||||
|
| Missing redaction before persistence/egress | Gateway and log redaction coverage verifies sensitive content is classified before durable storage or channel delivery. |
|
||||||
|
| In-memory-only restart safety | `packages/agent/src/durable-session.test.ts` reconstructs durable identity, inbox/outbox, checkpoints, and handoffs after simulated restart. |
|
||||||
|
| Globally scoped session GC | `apps/gateway/src/gc/session-gc.service.spec.ts` verifies session-only collection and the absence of automatic global collection entry points. |
|
||||||
|
|
||||||
|
These controls remain subject to the runtime's independent review and release qualification gates.
|
||||||
|
|||||||
5
docs/tess/USER-GUIDE.md
Normal file
5
docs/tess/USER-GUIDE.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Tess User Guide
|
||||||
|
|
||||||
|
All HTTP interaction calls require authenticated session credentials and `X-Correlation-Id`. Use `GET /api/interaction/{agentName}/sessions?provider=...` to list only visible runtime sessions, then enroll with `POST .../sessions/{sessionId}/enroll` body `{providerId,runtimeSessionId}`. Attach uses `{mode:"read"}`; send uses `{content,idempotencyKey}`. Stop requires `{approvalRef}` and fails with 403 without the exact durable approval. Recovery only requeues interrupted durable work.
|
||||||
|
|
||||||
|
Memory is user-scoped: preferences support list/get/upsert/delete; insights support list/get/create/delete; search body is `{query,limit?,maxDistance?}`. Mos work is handed off with `POST /api/coord/mos/handoff`; observe and result use the returned handoff ID.
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
135
tools/mos-comms/MOS-COMMS-PROTOCOL.md
Normal file
135
tools/mos-comms/MOS-COMMS-PROTOCOL.md
Normal 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.
|
||||||
23
tools/mos-comms/mos-comms.config.example
Normal file
23
tools/mos-comms/mos-comms.config.example
Normal 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
291
tools/mos-comms/mos-comms.sh
Executable 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
|
||||||
6
tools/mos-comms/systemd/mos-comms.service
Normal file
6
tools/mos-comms/systemd/mos-comms.service
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mos cross-host git communications poll
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=%h/.local/bin/mos-comms.sh poll
|
||||||
9
tools/mos-comms/systemd/mos-comms.timer
Normal file
9
tools/mos-comms/systemd/mos-comms.timer
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Poll Mos cross-host git communications
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitActiveSec=15min
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Reference in New Issue
Block a user