Compare commits

..
Author SHA1 Message Date
jason.woltjeandClaude Opus 4.8 f16f206a0a feat(gateway): expose generic harness catalog and selection
ci/woodpecker/pr/ci Pipeline failed
Add the Slice-Zero catalog and selection HTTP surfaces for P3 Task 3:
GET /api/harnesses, GET /api/harnesses/:harnessId/catalog,
GET+PUT /api/chat/preferences/selection. Scope is always server-derived
via scopeFromUser(CurrentUser); selection tuples are validated against the
live catalog with no fallback substitution and persisted in a transitional
owner-scoped in-memory store. HarnessModule is wired into AppModule.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
2026-08-11 19:56:11 -05:00
jason.woltje a186922e3a Merge pull request 'feat(gateway): harness registry, fake adapter, no-substitution suite (P3 Slice Zero, Task 2)' (#1168) from feat/p3-slice0-task2-harness-registry into next
ci/woodpecker/push/publish Pipeline failed
2026-08-12 00:40:03 +00:00
jason.woltje 43513c28f7 feat(gateway): add harness registry and fake adapter
ci/woodpecker/pr/ci Pipeline failed
2026-08-11 19:22:27 -05:00
jason.woltje fb9f9cda5a Merge pull request 'fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)' (#1167) from fix/1145-coord-di-compiled-boot into next
ci/woodpecker/push/publish Pipeline failed
2026-08-11 23:57:43 +00:00
jason.woltje 400a21ca18 Merge pull request 'feat(types): generic harness contracts (P3 Slice Zero, Task 1)' (#1166) from feat/p3-slice0-task1-harness-contracts into next
ci/woodpecker/push/publish Pipeline is pending
2026-08-11 23:51:50 +00:00
shaggy (mosaic-dev box) 4cefa5cd88 fix(gateway): resolve InteractionCoordinationService handoff factory via optional DI token (#1145)
ci/woodpecker/pr/ci Pipeline failed
root cause: emitDecoratorMetadata reflected the third constructor parameter as Function and Nest attempted to resolve it

fix: optional HANDOFF_ID_FACTORY injection token, no production provider, preserving undefined -> crypto.randomUUID() default and unchanged positional construction

TDD: real CoordModule red at Function index [2], then green; test overrides only unrelated AuthGuard because its AUTH provider comes from AppModule's global AuthModule context

Closes #1145
2026-08-11 18:37:54 -05:00
shaggy (mosaic-dev box) ddf8616716 feat(types): add generic harness contracts
ci/woodpecker/pr/ci Pipeline was canceled
2026-08-11 18:32:39 -05:00
28 changed files with 2580 additions and 144 deletions
+2
View File
@@ -21,6 +21,7 @@ import { AdminModule } from './admin/admin.module.js';
import { CommandsModule } from './commands/commands.module.js'; import { CommandsModule } from './commands/commands.module.js';
import { PreferencesModule } from './preferences/preferences.module.js'; import { PreferencesModule } from './preferences/preferences.module.js';
import { GCModule } from './gc/gc.module.js'; import { GCModule } from './gc/gc.module.js';
import { HarnessModule } from './harness/harness.module.js';
import { ReloadModule } from './reload/reload.module.js'; import { ReloadModule } from './reload/reload.module.js';
import { WorkspaceModule } from './workspace/workspace.module.js'; import { WorkspaceModule } from './workspace/workspace.module.js';
import { QueueModule } from './queue/queue.module.js'; import { QueueModule } from './queue/queue.module.js';
@@ -60,6 +61,7 @@ const federationEnabled = loadConfig(resolveGatewayConfigPath()).tier === 'feder
PreferencesModule, PreferencesModule,
CommandsModule, CommandsModule,
GCModule, GCModule,
HarnessModule,
QueueModule, QueueModule,
ReloadModule, ReloadModule,
WorkspaceModule, WorkspaceModule,
@@ -0,0 +1,19 @@
import 'reflect-metadata';
import { Test } from '@nestjs/testing';
import { describe, expect, it } from 'vitest';
import { CoordModule } from './coord.module.js';
import { InteractionCoordinationService } from './interaction-coordination.service.js';
import { AuthGuard } from '../auth/auth.guard.js';
describe('CoordModule DI (compiled-metadata boot)', () => {
it('resolves InteractionCoordinationService through Nest DI', async () => {
const moduleRef = await Test.createTestingModule({ imports: [CoordModule] })
.overrideGuard(AuthGuard)
.useValue({ canActivate: (): boolean => true })
.compile();
expect(moduleRef.get(InteractionCoordinationService)).toBeInstanceOf(
InteractionCoordinationService,
);
await moduleRef.close();
});
});
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import { import {
InteractionCoordinationClient, InteractionCoordinationClient,
type CoordinationObservation, type CoordinationObservation,
@@ -13,6 +13,7 @@ import type { CreateHandoffDto } from './interaction-coordination.dto.js';
export const COORDINATION_PORT = Symbol('COORDINATION_PORT'); export const COORDINATION_PORT = Symbol('COORDINATION_PORT');
export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG'); export const COORDINATION_CONFIG = Symbol('COORDINATION_CONFIG');
export const HANDOFF_ID_FACTORY = Symbol('HANDOFF_ID_FACTORY');
const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000; const HANDOFF_TRACKING_TTL_MS = 60 * 60 * 1_000;
const MAX_TRACKED_HANDOFFS = 1_000; const MAX_TRACKED_HANDOFFS = 1_000;
@@ -60,6 +61,8 @@ export class InteractionCoordinationService {
constructor( constructor(
@Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort, @Inject(COORDINATION_PORT) private readonly port: InteractionCoordinationPort,
@Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig, @Inject(COORDINATION_CONFIG) private readonly config: InteractionCoordinationConfig,
@Optional()
@Inject(HANDOFF_ID_FACTORY)
private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(), private readonly handoffIdFactory: () => string = (): string => crypto.randomUUID(),
) {} ) {}
@@ -0,0 +1,164 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
// Import the REAL module (not a hand-listed controllers+mocks list) so an
// unresolved provider fails at app.init() — the #1145-class DI-boot guard.
import { HarnessModule } from './harness.module.js';
// A known-available tuple from the fake adapter's default catalog.
const VALID = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-mini' };
// A tuple whose provider/model are not in any catalog.
const UNKNOWN = { harnessId: 'fake', providerId: 'ghost-provider', modelId: 'ghost-model' };
// A tuple that is known in the catalog but flagged unavailable.
const UNAVAILABLE = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-legacy' };
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
function registryWithFake(): HarnessRegistry {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
return registry;
}
describe('Harness selection HTTP surface', () => {
let app: INestApplication;
let repository: HarnessSelectionRepository;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWithFake())
.compile();
// Real in-memory repository from the module graph — proves the module wired it.
repository = moduleRef.get(HarnessSelectionRepository);
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
beforeEach(() => {
// Reset owner-scoped state between tests via the public API surface.
repository.set({ userId: 'user-1', tenantId: 'user-1' }, VALID);
});
afterAll(async () => {
await app.close();
});
it('GET selection is server-scoped and ignores caller-supplied scope in the query', async () => {
const response = await request(app.getHttpServer())
.get('/api/chat/preferences/selection')
.query({ userId: 'attacker', tenantId: 'attacker-tenant', seatId: 'attacker-seat' });
expect(response.status).toBe(200);
// The returned selection is user-1's (guard-derived scope), not the query's.
expect(response.body.selection).toEqual(VALID);
});
it('PUT with a valid structured tuple persists and round-trips via GET', async () => {
const next = { harnessId: 'fake', providerId: 'fake-openai', modelId: 'fake-pro' };
const put = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(next)
.set('Content-Type', 'application/json');
expect(put.status).toBe(200);
expect(put.body.selection).toEqual(next);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.status).toBe(200);
expect(get.body.selection).toEqual(next);
});
it('PUT with FREE TEXT is rejected 400 and does not mutate the stored selection', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send({ selection: 'gpt-4o' })
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
it.each([
['seatId', { ...VALID, seatId: 'attacker-seat' }],
['tenantId', { ...VALID, tenantId: 'attacker-tenant' }],
['userId', { ...VALID, userId: 'attacker' }],
['nativeSessionPath', { ...VALID, nativeSessionPath: '/var/native/x.jsonl' }],
['executable', { ...VALID, executable: '/usr/bin/evil' }],
['home', { ...VALID, home: '/home/attacker' }],
['cwd', { ...VALID, cwd: '/tmp/attacker' }],
])(
'PUT with an extra authority-bearing field (%s) is rejected 400 and does not mutate stored selection',
async (_name, body) => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(body)
.set('Content-Type', 'application/json');
expect(response.status).toBe(400);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
},
);
it('PUT with an UNKNOWN tuple returns selection_invalid, unchanged and echoed unchanged (no fallback)', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(UNKNOWN)
.set('Content-Type', 'application/json');
expect(response.status).toBe(422);
expect(response.body.code).toBe('selection_invalid');
// Echoed back unchanged: no first-row / first-provider substitution.
expect(response.body.selection).toEqual(UNKNOWN);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
it('PUT with a KNOWN-but-UNAVAILABLE tuple returns model_unavailable, unchanged (distinct from selection_invalid)', async () => {
const response = await request(app.getHttpServer())
.put('/api/chat/preferences/selection')
.send(UNAVAILABLE)
.set('Content-Type', 'application/json');
expect(response.status).toBe(422);
expect(response.body.code).toBe('model_unavailable');
expect(response.body.selection).toEqual(UNAVAILABLE);
const get = await request(app.getHttpServer()).get('/api/chat/preferences/selection');
expect(get.body.selection).toEqual(VALID);
});
});
@@ -0,0 +1,46 @@
import { Body, Controller, Get, HttpException, HttpStatus, Put, UseGuards } from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { HarnessOperationError } from './harness.registry.js';
import { HarnessSelectionService } from './harness-selection.service.js';
import { HarnessSelectionInputDto, type SelectionResponseDto } from './harness.dto.js';
/**
* Chat-preferences selection surface. The scope is ALWAYS derived on the server
* from the authenticated user (`scopeFromUser(CurrentUser)`); the request body and
* query string can never name another user, tenant, or seat. A typed selection
* failure (unknown tuple → `selection_invalid`, known-but-unavailable →
* `model_unavailable`) is returned as 422 with the requested tuple echoed back
* unchanged, and never mutates the stored selection.
*/
@Controller('api/chat/preferences/selection')
@UseGuards(AuthGuard)
export class HarnessSelectionController {
constructor(private readonly selection: HarnessSelectionService) {}
@Get()
get(@CurrentUser() user: AuthenticatedUserLike): SelectionResponseDto {
return { selection: this.selection.getSelection(scopeFromUser(user)) };
}
@Put()
async put(
@CurrentUser() user: AuthenticatedUserLike,
@Body() dto: HarnessSelectionInputDto,
): Promise<SelectionResponseDto> {
try {
const stored = await this.selection.setSelection(scopeFromUser(user), {
harnessId: dto.harnessId,
providerId: dto.providerId,
modelId: dto.modelId,
});
return { selection: stored };
} catch (error) {
if (error instanceof HarnessOperationError) {
throw new HttpException(error.dto, HttpStatus.UNPROCESSABLE_ENTITY);
}
throw error;
}
}
}
@@ -0,0 +1,90 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import type { HarnessSelection } from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
import {
HarnessAdapterUnavailableError,
HarnessRegistry,
operationError,
} from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { readContextFromScope } from './harness.dto.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
/**
* Selection logic for the Slice-Zero chat-preferences surface. It validates the
* requested harness/provider/model tuple against the live catalog with NO
* fallback substitution, then persists it owner-scoped. The stored selection is
* only ever mutated when the tuple is valid AND available.
*/
@Injectable()
export class HarnessSelectionService {
constructor(
@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry,
private readonly repository: HarnessSelectionRepository,
) {}
getSelection(scope: ActorTenantScope): HarnessSelection | null {
return this.repository.get(scope);
}
async setSelection(
scope: ActorTenantScope,
selection: HarnessSelection,
): Promise<HarnessSelection> {
// Throws HarnessOperationError (selection_invalid / model_unavailable) with the
// requested tuple echoed back unchanged. The store is untouched on any throw.
await this.assertSelectionAvailable(scope, selection);
return this.repository.set(scope, selection);
}
private async assertSelectionAvailable(
scope: ActorTenantScope,
selection: HarnessSelection,
): Promise<void> {
const correlationId = randomUUID();
let adapter;
try {
adapter = this.registry.get(selection.harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
// An unknown harness makes the whole tuple invalid — no fallback adapter.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
correlationId,
);
}
throw error;
}
const catalog = await adapter.catalog(readContextFromScope(scope));
const entry = catalog.models.find(
(candidate) =>
candidate.harnessId === selection.harnessId &&
candidate.providerId === selection.providerId &&
candidate.modelId === selection.modelId,
);
if (!entry) {
// No first-row / first-provider fallback: reject the requested tuple unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
correlationId,
true,
);
}
}
}
@@ -0,0 +1,138 @@
import 'reflect-metadata';
import {
type CanActivate,
type ExecutionContext,
type INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { AuthGuard } from '../auth/auth.guard.js';
import { HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
// The real module under test — importing it (not a hand-listed controllers/mocks
// list) is what makes an unresolved provider fail loudly at app.init() (#1145 guard).
import { HarnessModule } from './harness.module.js';
// Fields that must NEVER surface on a browser-facing catalog/list response.
const FORBIDDEN_KEYS = [
'executable',
'executablePath',
'home',
'homeDir',
'cwd',
'workingDir',
'workingDirectory',
'nativeSessionPath',
'sessionPath',
'env',
'secret',
'secrets',
'token',
'apiKey',
];
function assertNoForbiddenLeak(payload: unknown): void {
const serialized = JSON.stringify(payload).toLowerCase();
for (const key of FORBIDDEN_KEYS) {
expect(serialized).not.toContain(key.toLowerCase());
}
}
const authGuard: CanActivate = {
canActivate(context: ExecutionContext): boolean {
const requestContext = context.switchToHttp().getRequest<{ user?: { id: string } }>();
requestContext.user = { id: 'user-1' };
return true;
},
};
function registryWithFake(): HarnessRegistry {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
return registry;
}
describe('Harness catalog HTTP surface', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [HarnessModule],
})
.overrideGuard(AuthGuard)
.useValue(authGuard)
.overrideProvider(HARNESS_REGISTRY)
.useValue(registryWithFake())
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }),
);
await app.init();
await app.getHttpAdapter().getInstance().ready();
});
afterAll(async () => {
await app.close();
});
it('boots the real HarnessModule so all providers resolve at app.init()', () => {
// If HarnessModule failed to resolve a provider, beforeAll's app.init() would
// have thrown and this suite would never reach here.
expect(app).toBeDefined();
});
it('GET /api/harnesses returns 200 with safe fields only', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
expect(response.body.length).toBeGreaterThan(0);
const summary = response.body[0];
expect(Object.keys(summary).sort()).toEqual(['capabilities', 'displayName', 'id']);
expect(summary.id).toBe('fake');
expect(typeof summary.displayName).toBe('string');
expect(Array.isArray(summary.capabilities)).toBe(true);
assertNoForbiddenLeak(response.body);
});
it('GET /api/harnesses/:harnessId/catalog returns 200 with safe catalog fields only', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses/fake/catalog');
expect(response.status).toBe(200);
expect(response.body.harnessId).toBe('fake');
expect(typeof response.body.version).toBe('string');
expect(typeof response.body.fingerprint).toBe('string');
expect(Array.isArray(response.body.models)).toBe(true);
expect(response.body.models.length).toBeGreaterThan(0);
const entry = response.body.models[0];
// Whitelisted catalog-entry fields only (no executables/paths/secrets).
expect(Object.keys(entry).sort()).toEqual(
[
'authState',
'availability',
'displayName',
'harnessId',
'inputTypes',
'modelId',
'providerId',
'reasoningCapability',
].sort(),
);
assertNoForbiddenLeak(response.body);
});
it('GET catalog for an unknown harnessId returns a typed adapter_unavailable error, never a fallback catalog', async () => {
const response = await request(app.getHttpServer()).get('/api/harnesses/ghost-harness/catalog');
expect(response.status).toBe(404);
expect(response.body.code).toBe('adapter_unavailable');
// A fallback catalog would carry a models array; a typed error must not.
expect(response.body.models).toBeUndefined();
});
});
@@ -0,0 +1,65 @@
import {
Controller,
Get,
HttpException,
HttpStatus,
Inject,
Param,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '../auth/auth.guard.js';
import { CurrentUser } from '../auth/current-user.decorator.js';
import { scopeFromUser, type AuthenticatedUserLike } from '../auth/session-scope.js';
import { HarnessAdapterUnavailableError, HarnessRegistry } from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
import {
readContextFromScope,
toHarnessSummary,
toSafeCatalog,
type HarnessCatalogDto,
type HarnessSummaryDto,
} from './harness.dto.js';
/**
* Generic harness catalog surface. It exposes only harness-neutral, browser-safe
* fields (identity, capabilities, provider/model catalog) — never executables,
* native paths, home/cwd, env, or secrets. There is NO provider-probe route here;
* `/api/providers` and `POST /api/providers/test` are intentionally out of scope.
*/
@Controller('api/harnesses')
@UseGuards(AuthGuard)
export class HarnessController {
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
@Get()
async list(@CurrentUser() user: AuthenticatedUserLike): Promise<HarnessSummaryDto[]> {
const context = readContextFromScope(scopeFromUser(user));
const summaries: HarnessSummaryDto[] = [];
for (const adapter of this.registry.list()) {
summaries.push(toHarnessSummary(await adapter.describe(context)));
}
return summaries;
}
@Get(':harnessId/catalog')
async catalog(
@CurrentUser() user: AuthenticatedUserLike,
@Param('harnessId') harnessId: string,
): Promise<HarnessCatalogDto> {
const context = readContextFromScope(scopeFromUser(user));
let adapter;
try {
adapter = this.registry.get(harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
// Typed failure — NEVER a fallback catalog for an unknown harness id.
throw new HttpException(
{ code: error.code, message: error.message, harnessId },
HttpStatus.NOT_FOUND,
);
}
throw error;
}
return toSafeCatalog(await adapter.catalog(context));
}
}
+116
View File
@@ -0,0 +1,116 @@
import { randomUUID } from 'node:crypto';
import { IsNotEmpty, IsString } from 'class-validator';
import type {
HarnessActorContext,
HarnessAuthState,
HarnessCapability,
HarnessCatalog,
HarnessCatalogEntry,
HarnessDescriptor,
HarnessInputType,
HarnessModelAvailability,
HarnessSelection,
} from '@mosaicstack/types';
import type { ActorTenantScope } from '../auth/session-scope.js';
/**
* Structured selection tuple accepted on `PUT /api/chat/preferences/selection`.
*
* The body is a STRUCTURED tuple (harness + provider + model), never a free-text
* model string. With `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })`
* any extra property — including smuggled server-authority fields such as
* `seatId`, `tenantId`, `userId`, `nativeSessionPath`, `executable`, `home`, `cwd` —
* is rejected with 400. There is deliberately no field through which a caller can
* name a scope; scope is derived on the server from the authenticated session.
*/
export class HarnessSelectionInputDto {
@IsString()
@IsNotEmpty()
harnessId!: string;
@IsString()
@IsNotEmpty()
providerId!: string;
@IsString()
@IsNotEmpty()
modelId!: string;
}
/** Browser-safe harness summary — identity and capabilities only. */
export interface HarnessSummaryDto {
readonly id: string;
readonly displayName: string;
readonly capabilities: readonly HarnessCapability[];
}
/** Browser-safe catalog entry — no executables, paths, secrets, or env. */
export interface HarnessCatalogEntryDto {
readonly harnessId: string;
readonly providerId: string;
readonly modelId: string;
readonly displayName: string;
readonly reasoningCapability: boolean;
readonly inputTypes: readonly HarnessInputType[];
readonly authState: HarnessAuthState;
readonly availability: HarnessModelAvailability;
}
/** Browser-safe catalog envelope. */
export interface HarnessCatalogDto {
readonly harnessId: string;
readonly version: string;
readonly fingerprint: string;
readonly models: readonly HarnessCatalogEntryDto[];
}
/** Response envelope for the caller's current selection (null when unset). */
export interface SelectionResponseDto {
readonly selection: HarnessSelection | null;
}
/**
* Derive a server-trusted {@link HarnessActorContext} for read operations from the
* session-derived {@link ActorTenantScope}. All authority originates on the server;
* nothing here is caller-supplied. A fresh correlation id is minted per call.
*/
export function readContextFromScope(scope: ActorTenantScope): HarnessActorContext {
return {
actorId: scope.userId,
tenantId: scope.tenantId,
seatId: scope.userId,
correlationId: randomUUID(),
};
}
/** Project a descriptor onto the browser-safe summary shape (whitelist by construction). */
export function toHarnessSummary(descriptor: HarnessDescriptor): HarnessSummaryDto {
return {
id: descriptor.id,
displayName: descriptor.displayName,
capabilities: [...descriptor.capabilities],
};
}
/** Project a catalog onto the browser-safe shape (whitelist by construction). */
export function toSafeCatalog(catalog: HarnessCatalog): HarnessCatalogDto {
return {
harnessId: catalog.harnessId,
version: catalog.version,
fingerprint: catalog.fingerprint,
models: catalog.models.map(toSafeCatalogEntry),
};
}
function toSafeCatalogEntry(entry: HarnessCatalogEntry): HarnessCatalogEntryDto {
return {
harnessId: entry.harnessId,
providerId: entry.providerId,
modelId: entry.modelId,
displayName: entry.displayName,
reasoningCapability: entry.reasoningCapability,
inputTypes: [...entry.inputTypes],
authState: entry.authState,
availability: entry.availability,
};
}
@@ -0,0 +1,28 @@
import { Module } from '@nestjs/common';
import { HarnessRegistry } from './harness.registry.js';
import { HarnessService } from './harness.service.js';
import { HARNESS_REGISTRY, HARNESS_SERVICE } from './harness.tokens.js';
import { HarnessController } from './harness.controller.js';
import { HarnessSelectionController } from './harness-selection.controller.js';
import { HarnessSelectionService } from './harness-selection.service.js';
import { HarnessSelectionRepository } from './harness-selection.repository.js';
/**
* Wires the harness-neutral registry/service (Task Two) together with the
* Slice-Zero catalog and selection HTTP surfaces (Task Three).
*
* The registry is provided empty here; real harness adapters are registered in a
* later task. Because the controllers/services resolve their collaborators through
* this real module graph, an unresolved provider fails loudly at `app.init()`.
*/
@Module({
controllers: [HarnessController, HarnessSelectionController],
providers: [
{ provide: HARNESS_REGISTRY, useFactory: () => new HarnessRegistry() },
{ provide: HARNESS_SERVICE, useClass: HarnessService },
HarnessSelectionRepository,
HarnessSelectionService,
],
exports: [HARNESS_REGISTRY, HARNESS_SERVICE],
})
export class HarnessModule {}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import {
HarnessAdapterUnavailableError,
HarnessRegistrationError,
HarnessRegistry,
} from './harness.registry.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
describe('HarnessRegistry', () => {
it('registers and looks up an adapter by harness id', () => {
const registry = new HarnessRegistry();
const adapter = new FakeHarnessAdapter({ id: 'fake' });
registry.register(adapter);
expect(registry.get('fake')).toBe(adapter);
expect(registry.has('fake')).toBe(true);
expect(registry.list().map((entry) => entry.id)).toEqual(['fake']);
});
it('rejects a blank adapter id', () => {
const registry = new HarnessRegistry();
let error: unknown;
try {
registry.register(new FakeHarnessAdapter({ id: ' ' }));
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessRegistrationError);
expect((error as HarnessRegistrationError).reason).toBe('blank_id');
expect(registry.list()).toEqual([]);
});
it('rejects a duplicate adapter id', () => {
const registry = new HarnessRegistry();
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
let error: unknown;
try {
registry.register(new FakeHarnessAdapter({ id: 'fake' }));
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessRegistrationError);
expect((error as HarnessRegistrationError).reason).toBe('duplicate_id');
expect((error as HarnessRegistrationError).harnessId).toBe('fake');
// The original registration is untouched.
expect(registry.list()).toHaveLength(1);
});
it('returns adapter_unavailable for an unknown harness id', () => {
const registry = new HarnessRegistry();
let error: unknown;
try {
registry.get('missing');
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessAdapterUnavailableError);
expect((error as HarnessAdapterUnavailableError).code).toBe('adapter_unavailable');
expect((error as HarnessAdapterUnavailableError).harnessId).toBe('missing');
expect(registry.has('missing')).toBe(false);
});
});
@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import type {
HarnessAdapter,
HarnessErrorCode,
HarnessErrorDto,
HarnessSelection,
} from '@mosaicstack/types';
/**
* A typed harness operation failure that carries a fully-formed, browser-safe
* {@link HarnessErrorDto}. The DTO's `selection` is always the exact requested
* tuple — there is no field through which a substituted "effective" selection
* could ever be reported.
*/
export class HarnessOperationError extends Error {
readonly code: HarnessErrorCode;
readonly dto: HarnessErrorDto;
constructor(dto: HarnessErrorDto) {
super(dto.message);
this.name = 'HarnessOperationError';
this.code = dto.code;
this.dto = dto;
}
}
/** Build a {@link HarnessOperationError} that echoes the requested selection unchanged. */
export function operationError(
code: HarnessErrorCode,
message: string,
selection: HarnessSelection,
correlationId: string,
retryable = false,
): HarnessOperationError {
return new HarnessOperationError({ code, message, retryable, correlationId, selection });
}
/** Raised when an unknown harness id is looked up. Discriminated by `code`. */
export class HarnessAdapterUnavailableError extends Error {
readonly code = 'adapter_unavailable' as const satisfies HarnessErrorCode;
constructor(readonly harnessId: string) {
super(`No harness adapter is registered for id "${harnessId}".`);
this.name = 'HarnessAdapterUnavailableError';
}
}
export type HarnessRegistrationFailure = 'blank_id' | 'duplicate_id';
/** Raised when an adapter cannot be registered (blank or duplicate id). */
export class HarnessRegistrationError extends Error {
constructor(
readonly reason: HarnessRegistrationFailure,
readonly harnessId: string,
) {
super(
reason === 'blank_id'
? 'A harness adapter id must be a non-empty string.'
: `A harness adapter is already registered for id "${harnessId}".`,
);
this.name = 'HarnessRegistrationError';
}
}
/**
* Harness-neutral adapter registry. Adapters are keyed by their harness id.
* Registration rejects blank and duplicate ids; lookup of an unknown id fails
* with {@link HarnessAdapterUnavailableError} (`adapter_unavailable`).
*/
@Injectable()
export class HarnessRegistry {
private readonly adapters = new Map<string, HarnessAdapter>();
register(adapter: HarnessAdapter): void {
const id = adapter.id;
if (typeof id !== 'string' || id.trim().length === 0) {
throw new HarnessRegistrationError('blank_id', id ?? '');
}
if (this.adapters.has(id)) {
throw new HarnessRegistrationError('duplicate_id', id);
}
this.adapters.set(id, adapter);
}
get(harnessId: string): HarnessAdapter {
const adapter = this.adapters.get(harnessId);
if (!adapter) {
throw new HarnessAdapterUnavailableError(harnessId);
}
return adapter;
}
has(harnessId: string): boolean {
return this.adapters.has(harnessId);
}
list(): readonly HarnessAdapter[] {
return [...this.adapters.values()];
}
}
@@ -0,0 +1,227 @@
import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessCapability, HarnessSelection } from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { HarnessOperationError, HarnessRegistry } from './harness.registry.js';
import {
HarnessScopeViolationError,
HarnessService,
type TrustedGatewayScope,
} from './harness.service.js';
import { FakeHarnessAdapter } from './testing/fake-harness.adapter.js';
const SCOPE: TrustedGatewayScope = {
actorId: 'actor-trusted',
tenantId: 'tenant-trusted',
seatId: 'seat-trusted',
correlationId: 'correlation-trusted',
};
const READ_CONTEXT: HarnessActorContext = {
actorId: SCOPE.actorId,
tenantId: SCOPE.tenantId,
seatId: SCOPE.seatId,
correlationId: SCOPE.correlationId,
};
function setup(capabilities?: readonly HarnessCapability[]) {
const registry = new HarnessRegistry();
const adapter = new FakeHarnessAdapter({ id: 'fake', capabilities });
registry.register(adapter);
const service = new HarnessService(registry);
return { registry, adapter, service };
}
async function availableSelection(adapter: FakeHarnessAdapter): Promise<HarnessSelection> {
const catalog = await adapter.catalog(READ_CONTEXT);
const entry = catalog.models.find((model) => model.availability === 'available');
if (!entry) {
throw new Error('fixture requires an available model');
}
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}
describe('HarnessService', () => {
it('derives the actor context from trusted scope on create', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const snapshot = await service.createSession(SCOPE, {
conversationId: 'conversation-1',
selection,
});
expect(snapshot.seatId).toBe(SCOPE.seatId);
expect(snapshot.state).toBe('idle');
expect(snapshot.selection).toEqual(selection);
expect(snapshot.nativeSessionId).toBeTruthy();
});
it('rejects server-authority fields supplied by an external caller', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const hostile = {
conversationId: 'conversation-1',
selection,
seatId: 'attacker-seat',
executablePath: '/usr/bin/evil',
home: '/home/attacker',
cwd: '/tmp/attacker',
nativeSessionPath: '/var/native/attacker.jsonl',
} as unknown as Parameters<HarnessService['createSession']>[1];
let error: unknown;
try {
await service.createSession(SCOPE, hostile);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessScopeViolationError);
expect((error as HarnessScopeViolationError).field).toBe('seatId');
});
it('returns adapter_unavailable for an unknown harness id, echoing the requested tuple', async () => {
const { service } = setup();
const selection: HarnessSelection = {
harnessId: 'ghost-harness',
providerId: 'p',
modelId: 'm',
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('adapter_unavailable');
expect(dto.selection).toEqual(selection);
expect(dto.correlationId).toBe(SCOPE.correlationId);
});
it('returns selection_invalid for an unknown provider/model tuple, unchanged', async () => {
const { service } = setup();
const selection: HarnessSelection = {
harnessId: 'fake',
providerId: 'ghost-provider',
modelId: 'ghost-model',
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
expect(dto.selection).toEqual(selection);
});
it('returns model_unavailable without falling back for a known unavailable model', async () => {
const { service, adapter } = setup();
const catalog = await adapter.catalog(READ_CONTEXT);
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
expect(unavailable).toBeDefined();
const selection: HarnessSelection = {
harnessId: unavailable!.harnessId,
providerId: unavailable!.providerId,
modelId: unavailable!.modelId,
};
let error: unknown;
try {
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('model_unavailable');
// No substitution: the DTO tuple is exactly what was requested.
expect(dto.selection).toEqual(selection);
});
it('gives create, resume, detach, evict, and end distinct observable effects', async () => {
const { service, adapter } = setup();
const selection = await availableSelection(adapter);
const created = await service.createSession(SCOPE, {
conversationId: 'conversation-create',
selection,
});
expect(created.state).toBe('idle');
expect(created.processId).toBeTruthy();
expect(created.attachedClientIds).toEqual([]);
const resumed = await service.resumeSession(SCOPE, {
conversationId: 'conversation-resume',
nativeSessionId: 'native-preexisting-123',
selection,
});
// Resume binds the supplied native session; create mints a fresh one.
expect(resumed.nativeSessionId).toBe('native-preexisting-123');
expect(resumed.nativeSessionId).not.toBe(created.nativeSessionId);
await service.attach(SCOPE, {
conversationId: 'conversation-create',
clientId: 'browser-1',
});
const afterAttach = await service.snapshot(SCOPE, 'conversation-create');
expect(afterAttach.attachedClientIds).toEqual(['browser-1']);
const afterDetach = await service.detach(SCOPE, {
conversationId: 'conversation-create',
clientId: 'browser-1',
});
// Detach removes the browser attachment only; the process stays alive.
expect(afterDetach.attachedClientIds).toEqual([]);
expect(afterDetach.state).toBe('idle');
expect(afterDetach.processId).toBeTruthy();
const afterEvict = await service.evict(SCOPE, {
conversationId: 'conversation-create',
reason: 'idle_timeout',
});
// Evict stops the process but retains the resumable native session.
expect(afterEvict.state).toBe('evicted');
expect(afterEvict.processId).toBeUndefined();
expect(afterEvict.nativeSessionId).toBe(created.nativeSessionId);
const afterEnd = await service.end(SCOPE, {
conversationId: 'conversation-create',
reason: 'session_ended',
});
// End destructively terminates the native session.
expect(afterEnd.state).toBe('ended');
});
it('fails typed when an unsupported capability is exercised', async () => {
const withoutExtensionUi = HARNESS_CAPABILITIES.filter(
(capability) => capability !== 'extensionUi',
);
const { service, adapter } = setup(withoutExtensionUi);
const selection = await availableSelection(adapter);
await service.createSession(SCOPE, { conversationId: 'conversation-1', selection });
let error: unknown;
try {
await service.respondInteraction(SCOPE, {
conversationId: 'conversation-1',
response: { requestId: 'interaction-1', type: 'confirm', accepted: true },
});
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
});
});
+285
View File
@@ -0,0 +1,285 @@
import { Inject, Injectable } from '@nestjs/common';
import type {
HarnessActorContext,
HarnessAdapter,
HarnessCatalog,
HarnessCloseReason,
HarnessInteractionResponse,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
} from '@mosaicstack/types';
import {
HarnessAdapterUnavailableError,
HarnessRegistry,
operationError,
} from './harness.registry.js';
import { HARNESS_REGISTRY } from './harness.tokens.js';
/**
* Trusted, server-derived authority. In production this is produced by the
* Gateway from the authenticated session — never from a browser/caller DTO.
*/
export interface TrustedGatewayScope {
readonly actorId: string;
readonly tenantId: string;
readonly seatId: string;
readonly correlationId: string;
}
/** Server-authority fields that must never arrive from an external request DTO. */
const FORBIDDEN_REQUEST_FIELDS = [
'actorId',
'tenantId',
'correlationId',
'seatId',
'seat',
'executable',
'executablePath',
'home',
'homeDir',
'cwd',
'workingDir',
'workingDirectory',
'nativeSessionPath',
'sessionPath',
] as const;
/** Raised when an external request DTO smuggles a server-authority field. */
export class HarnessScopeViolationError extends Error {
constructor(readonly field: string) {
super(`External request supplied server-authority field "${field}".`);
this.name = 'HarnessScopeViolationError';
}
}
export interface CreateHarnessSessionRequest {
readonly conversationId: string;
readonly selection: HarnessSelection;
}
export interface ResumeHarnessSessionRequest {
readonly conversationId: string;
readonly nativeSessionId: string;
readonly selection: HarnessSelection;
}
export interface AttachClientRequest {
readonly conversationId: string;
readonly clientId: string;
}
export interface DetachClientRequest {
readonly conversationId: string;
readonly clientId: string;
}
export interface EvictSessionRequest {
readonly conversationId: string;
readonly reason: HarnessCloseReason;
}
export interface EndSessionRequest {
readonly conversationId: string;
readonly reason: HarnessCloseReason;
}
export interface RespondInteractionRequest {
readonly conversationId: string;
readonly response: HarnessInteractionResponse;
}
interface ActiveSession {
readonly harnessId: string;
readonly handle: HarnessSessionHandle;
readonly correlationId: string;
}
/**
* Harness-neutral service. It derives the {@link HarnessActorContext} strictly
* from trusted Gateway scope, validates the selected provider/model tuple with
* NO fallback substitution, and exposes distinct create/resume/detach/evict/end
* lifecycle operations.
*/
@Injectable()
export class HarnessService {
private readonly sessions = new Map<string, ActiveSession>();
constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {}
async createSession(
scope: TrustedGatewayScope,
request: CreateHarnessSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const { conversationId, selection } = request;
const adapter = this.resolveAdapter(scope, selection);
const context = deriveActorContext(scope);
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
const handle = await adapter.create({ context, conversationId, selection });
this.sessions.set(conversationId, {
harnessId: selection.harnessId,
handle,
correlationId: scope.correlationId,
});
return handle.snapshot();
}
async resumeSession(
scope: TrustedGatewayScope,
request: ResumeHarnessSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const { conversationId, nativeSessionId, selection } = request;
const adapter = this.resolveAdapter(scope, selection);
const context = deriveActorContext(scope);
await this.assertSelectionAvailable(scope, adapter.catalog(context), selection);
const handle = await adapter.resume({ context, conversationId, nativeSessionId, selection });
this.sessions.set(conversationId, {
harnessId: selection.harnessId,
handle,
correlationId: scope.correlationId,
});
return handle.snapshot();
}
async attach(
scope: TrustedGatewayScope,
request: AttachClientRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.attach({ clientId: request.clientId });
return handle.snapshot();
}
async detach(
scope: TrustedGatewayScope,
request: DetachClientRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.detach(request.clientId);
return handle.snapshot();
}
async evict(
scope: TrustedGatewayScope,
request: EvictSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.evictProcess(request.reason);
return handle.snapshot();
}
async end(
scope: TrustedGatewayScope,
request: EndSessionRequest,
): Promise<HarnessSessionSnapshot> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.endSession(request.reason);
const snapshot = await handle.snapshot();
this.sessions.delete(request.conversationId);
return snapshot;
}
async respondInteraction(
scope: TrustedGatewayScope,
request: RespondInteractionRequest,
): Promise<void> {
assertTrustedRequest(request);
const handle = this.requireHandle(scope, request.conversationId);
await handle.respondInteraction(request.response);
}
async snapshot(
scope: TrustedGatewayScope,
conversationId: string,
): Promise<HarnessSessionSnapshot> {
const handle = this.requireHandle(scope, conversationId);
return handle.snapshot();
}
private resolveAdapter(scope: TrustedGatewayScope, selection: HarnessSelection): HarnessAdapter {
try {
return this.registry.get(selection.harnessId);
} catch (error) {
if (error instanceof HarnessAdapterUnavailableError) {
throw operationError('adapter_unavailable', error.message, selection, scope.correlationId);
}
throw error;
}
}
private async assertSelectionAvailable(
scope: TrustedGatewayScope,
catalogPromise: Promise<HarnessCatalog>,
selection: HarnessSelection,
): Promise<void> {
const catalog = await catalogPromise;
const entry = catalog.models.find(
(candidate) =>
candidate.harnessId === selection.harnessId &&
candidate.providerId === selection.providerId &&
candidate.modelId === selection.modelId,
);
if (!entry) {
// No first-row fallback: reject the requested tuple unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
scope.correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
scope.correlationId,
true,
);
}
}
private requireHandle(scope: TrustedGatewayScope, conversationId: string): HarnessSessionHandle {
const active = this.sessions.get(conversationId);
if (!active) {
throw operationError(
'session_not_found',
`No active harness session for conversation "${conversationId}".`,
{ harnessId: '', providerId: '', modelId: '' },
scope.correlationId,
);
}
return active.handle;
}
}
/** Build the actor context strictly from trusted scope. No caller data leaks in. */
export function deriveActorContext(scope: TrustedGatewayScope): HarnessActorContext {
return {
actorId: scope.actorId,
tenantId: scope.tenantId,
seatId: scope.seatId,
correlationId: scope.correlationId,
};
}
/** Reject any request object that carries a server-authority field. */
function assertTrustedRequest(request: object): void {
for (const field of FORBIDDEN_REQUEST_FIELDS) {
if (Object.prototype.hasOwnProperty.call(request, field)) {
throw new HarnessScopeViolationError(field);
}
}
}
// Re-export the typed operation error so callers importing from the service
// have the discriminated failure type without reaching into the registry.
export { HarnessOperationError } from './harness.registry.js';
@@ -0,0 +1,11 @@
/**
* Nest dependency-injection tokens for the harness-neutral registry and service.
*
* String tokens follow the existing Gateway convention (see `memory/memory.tokens.ts`)
* and remain valid Nest `InjectionToken`s for `@Inject(...)`.
*/
export const HARNESS_REGISTRY = 'HARNESS_REGISTRY' as const;
export const HARNESS_SERVICE = 'HARNESS_SERVICE' as const;
export type HarnessRegistryToken = typeof HARNESS_REGISTRY;
export type HarnessServiceToken = typeof HARNESS_SERVICE;
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import type { HarnessActorContext, HarnessSelection } from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';
import { FakeHarnessAdapter } from './fake-harness.adapter.js';
import { runHarnessAdapterContract } from './harness-adapter.contract.js';
const CONTEXT: HarnessActorContext = {
actorId: 'actor-1',
tenantId: 'tenant-1',
seatId: 'seat-1',
correlationId: 'correlation-1',
};
// The reusable conformance suite. Task 13 re-runs it against the native Pi adapter.
runHarnessAdapterContract('FakeHarnessAdapter', () => new FakeHarnessAdapter({ id: 'fake' }));
describe('FakeHarnessAdapter no-substitution', () => {
it('never substitutes the first catalog row when a bogus selection is requested', async () => {
const adapter = new FakeHarnessAdapter({ id: 'fake' });
const catalog = await adapter.catalog(CONTEXT);
const firstRow = catalog.models[0];
if (!firstRow) {
throw new Error('fixture requires a catalog model');
}
const available = catalog.models.find(
(entry) => entry.availability === 'available' && entry.modelId !== firstRow.modelId,
);
expect(available).toBeDefined();
const selected: HarnessSelection = {
harnessId: available!.harnessId,
providerId: available!.providerId,
modelId: available!.modelId,
};
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conversation-1',
selection: selected,
});
const bogus: HarnessSelection = {
harnessId: 'fake',
providerId: 'ghost-provider',
modelId: 'ghost-model',
};
let error: unknown;
try {
await handle.setModel(bogus);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
// The DTO echoes the exact requested tuple, unchanged.
expect(dto.selection).toEqual(bogus);
// No substitution to the first catalog row.
expect(dto.selection).not.toEqual({
harnessId: firstRow.harnessId,
providerId: firstRow.providerId,
modelId: firstRow.modelId,
});
// The active selection is untouched by the rejected request.
expect((await handle.snapshot()).selection).toEqual(selected);
});
it('reports model_unavailable with the unchanged tuple for a known but unavailable model', async () => {
const adapter = new FakeHarnessAdapter({ id: 'fake' });
const catalog = await adapter.catalog(CONTEXT);
const unavailable = catalog.models.find((entry) => entry.availability === 'unavailable');
const available = catalog.models.find((entry) => entry.availability === 'available');
expect(unavailable).toBeDefined();
expect(available).toBeDefined();
const startingSelection: HarnessSelection = {
harnessId: available!.harnessId,
providerId: available!.providerId,
modelId: available!.modelId,
};
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conversation-2',
selection: startingSelection,
});
const requested: HarnessSelection = {
harnessId: unavailable!.harnessId,
providerId: unavailable!.providerId,
modelId: unavailable!.modelId,
};
let error: unknown;
try {
await handle.setModel(requested);
} catch (caught) {
error = caught;
}
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('model_unavailable');
expect(dto.selection).toEqual(requested);
expect((await handle.snapshot()).selection).toEqual(startingSelection);
});
});
@@ -0,0 +1,248 @@
import type {
AttachClient,
CreateHarnessSession,
HarnessAdapter,
HarnessActorContext,
HarnessCapability,
HarnessCatalog,
HarnessCatalogEntry,
HarnessCloseReason,
HarnessDescriptor,
HarnessEvent,
HarnessInteractionResponse,
HarnessPrompt,
HarnessPromptReceipt,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
HarnessSessionState,
ResumeHarnessSession,
} from '@mosaicstack/types';
import { HARNESS_CAPABILITIES } from '@mosaicstack/types';
import { operationError } from '../harness.registry.js';
export interface FakeHarnessAdapterOptions {
readonly id: string;
readonly capabilities?: readonly HarnessCapability[];
readonly catalog?: readonly HarnessCatalogEntry[];
}
const FAKE_PROVIDER = 'fake-openai';
function defaultCatalog(harnessId: string): readonly HarnessCatalogEntry[] {
return [
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-mini',
displayName: 'Fake Mini',
reasoningCapability: false,
inputTypes: ['text'],
authState: 'ready',
availability: 'available',
},
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-pro',
displayName: 'Fake Pro',
reasoningCapability: true,
inputTypes: ['text', 'image'],
authState: 'ready',
availability: 'available',
},
{
harnessId,
providerId: FAKE_PROVIDER,
modelId: 'fake-legacy',
displayName: 'Fake Legacy',
reasoningCapability: false,
inputTypes: ['text'],
authState: 'unavailable',
availability: 'unavailable',
},
];
}
function matches(entry: HarnessCatalogEntry, selection: HarnessSelection): boolean {
return (
entry.harnessId === selection.harnessId &&
entry.providerId === selection.providerId &&
entry.modelId === selection.modelId
);
}
/**
* In-memory harness session handle used by the fake adapter and by the shared
* conformance suite. It enforces the two invariants the real adapters must also
* honor: model selection is validated against the catalog and is NEVER
* substituted, and unsupported capabilities fail with a typed error.
*/
export class FakeHarnessSessionHandle implements HarnessSessionHandle {
private state: HarnessSessionState = 'idle';
private processId: string | undefined;
private readonly attachedClientIds = new Set<string>();
private readonly listeners = new Set<(event: HarnessEvent) => void>();
constructor(
private readonly conversationId: string,
private readonly nativeSessionId: string,
private readonly seatId: string,
private selection: HarnessSelection,
private readonly correlationId: string,
private readonly capabilities: readonly HarnessCapability[],
private readonly catalog: readonly HarnessCatalogEntry[],
) {
this.processId = `process-${nativeSessionId}`;
}
async snapshot(): Promise<HarnessSessionSnapshot> {
return {
conversationId: this.conversationId,
nativeSessionId: this.nativeSessionId,
processId: this.processId,
seatId: this.seatId,
selection: this.selection,
state: this.state,
attachedClientIds: [...this.attachedClientIds],
};
}
async attach(input: AttachClient): Promise<void> {
this.attachedClientIds.add(input.clientId);
}
async detach(clientId: string): Promise<void> {
// Removes the browser attachment only; the process and native session persist.
this.attachedClientIds.delete(clientId);
}
async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt> {
return {
conversationId: this.conversationId,
turnId: input.turnId,
correlationId: input.correlationId,
state: 'accepted',
selection: this.selection,
};
}
async setModel(selection: HarnessSelection): Promise<HarnessSelection> {
const entry = this.catalog.find((candidate) => matches(candidate, selection));
if (!entry) {
// No fallback to the first catalog row: reject with the requested tuple, unchanged.
throw operationError(
'selection_invalid',
'The requested harness/provider/model tuple is not in the catalog.',
selection,
this.correlationId,
);
}
if (entry.availability === 'unavailable') {
throw operationError(
'model_unavailable',
'The requested model is currently unavailable.',
selection,
this.correlationId,
true,
);
}
this.selection = selection;
return this.selection;
}
async abort(_turnId: string): Promise<void> {
// No active turn machinery in the fake; abort is a no-op acknowledgement.
}
async respondInteraction(_input: HarnessInteractionResponse): Promise<void> {
if (!this.capabilities.includes('extensionUi')) {
throw operationError(
'interaction_unsupported',
'This harness does not support interactive responses.',
this.selection,
this.correlationId,
);
}
}
events(listener: (event: HarnessEvent) => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
async evictProcess(_reason: HarnessCloseReason): Promise<void> {
// Stop the process but keep the resumable native session.
this.processId = undefined;
this.state = 'evicted';
}
async endSession(_reason: HarnessCloseReason): Promise<void> {
// Destructively end the native session.
this.processId = undefined;
this.state = 'ended';
}
}
/**
* Minimal in-memory {@link HarnessAdapter} for Slice Zero. It mints a fresh
* native session id on `create` and binds the supplied one on `resume`, so the
* two paths are observably distinct.
*/
export class FakeHarnessAdapter implements HarnessAdapter {
readonly id: string;
private readonly capabilities: readonly HarnessCapability[];
private readonly catalogEntries: readonly HarnessCatalogEntry[];
private createdCount = 0;
constructor(options: FakeHarnessAdapterOptions) {
this.id = options.id;
this.capabilities = options.capabilities ?? [...HARNESS_CAPABILITIES];
this.catalogEntries = options.catalog ?? defaultCatalog(options.id);
}
async describe(_context: HarnessActorContext): Promise<HarnessDescriptor> {
return {
id: this.id,
displayName: `Fake harness (${this.id})`,
capabilities: this.capabilities,
};
}
async catalog(_context: HarnessActorContext): Promise<HarnessCatalog> {
return {
harnessId: this.id,
version: '1.0.0',
fingerprint: `fake-${this.id}-${this.catalogEntries.length}`,
models: this.catalogEntries,
};
}
async create(input: CreateHarnessSession): Promise<HarnessSessionHandle> {
this.createdCount += 1;
const nativeSessionId = `native-${input.conversationId}-${this.createdCount}`;
return new FakeHarnessSessionHandle(
input.conversationId,
nativeSessionId,
input.context.seatId,
input.selection,
input.context.correlationId,
this.capabilities,
this.catalogEntries,
);
}
async resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle> {
return new FakeHarnessSessionHandle(
input.conversationId,
input.nativeSessionId,
input.context.seatId,
input.selection,
input.context.correlationId,
this.capabilities,
this.catalogEntries,
);
}
}
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import type {
HarnessActorContext,
HarnessAdapter,
HarnessCatalogEntry,
HarnessSelection,
} from '@mosaicstack/types';
import { HarnessOperationError } from '../harness.registry.js';
const CONTEXT: HarnessActorContext = {
actorId: 'contract-actor',
tenantId: 'contract-tenant',
seatId: 'contract-seat',
correlationId: 'contract-correlation',
};
function toSelection(entry: HarnessCatalogEntry): HarnessSelection {
return { harnessId: entry.harnessId, providerId: entry.providerId, modelId: entry.modelId };
}
function pickAvailable(models: readonly HarnessCatalogEntry[]): HarnessCatalogEntry {
const entry = models.find((candidate) => candidate.availability === 'available') ?? models[0];
if (!entry) {
throw new Error('contract fixture requires at least one catalog model');
}
return entry;
}
async function captureError(run: () => Promise<unknown>): Promise<unknown> {
try {
await run();
return undefined;
} catch (caught) {
return caught;
}
}
/**
* Shared conformance suite every {@link HarnessAdapter} must pass. Slice Zero
* runs it against the fake adapter; Task 13 re-runs the identical suite against
* the native Pi adapter so both share one behavioral contract.
*/
export function runHarnessAdapterContract(
label: string,
createAdapter: () => HarnessAdapter,
): void {
describe(`harness adapter contract: ${label}`, () => {
it('mints a fresh native session on create and binds the supplied one on resume', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const created = await (
await adapter.create({ context: CONTEXT, conversationId: 'conv-create', selection })
).snapshot();
const resumed = await (
await adapter.resume({
context: CONTEXT,
conversationId: 'conv-resume',
nativeSessionId: 'native-supplied-1',
selection,
})
).snapshot();
expect(created.nativeSessionId).toBeTruthy();
expect(resumed.nativeSessionId).toBe('native-supplied-1');
expect(created.nativeSessionId).not.toBe(resumed.nativeSessionId);
expect(created.seatId).toBe(CONTEXT.seatId);
});
it('gives detach, evict, and end distinct effects (not aliases)', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-lifecycle',
selection,
});
await handle.attach({ clientId: 'browser-1' });
await handle.detach('browser-1');
const afterDetach = await handle.snapshot();
expect(afterDetach.attachedClientIds).toEqual([]);
expect(afterDetach.state).not.toBe('evicted');
expect(afterDetach.state).not.toBe('ended');
await handle.evictProcess('idle_timeout');
const afterEvict = await handle.snapshot();
expect(afterEvict.state).toBe('evicted');
// The native session survives eviction (resumable); the process does not.
expect(afterEvict.nativeSessionId).toBe(afterDetach.nativeSessionId);
expect(afterEvict.processId).toBeUndefined();
await handle.endSession('session_ended');
const afterEnd = await handle.snapshot();
expect(afterEnd.state).toBe('ended');
// End is not an alias of evict.
expect(afterEnd.state).not.toBe(afterEvict.state);
});
it('never substitutes the first catalog row for an unknown selection', async () => {
const adapter = createAdapter();
const catalog = await adapter.catalog(CONTEXT);
const firstRow = catalog.models[0];
if (!firstRow) {
throw new Error('contract fixture requires a catalog model');
}
const start = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-nosub',
selection: start,
});
const bogus: HarnessSelection = {
harnessId: adapter.id,
providerId: 'contract-ghost-provider',
modelId: 'contract-ghost-model',
};
const error = await captureError(() => handle.setModel(bogus));
expect(error).toBeInstanceOf(HarnessOperationError);
const dto = (error as HarnessOperationError).dto;
expect(dto.code).toBe('selection_invalid');
expect(dto.selection).toEqual(bogus);
expect(dto.selection).not.toEqual(toSelection(firstRow));
expect((await handle.snapshot()).selection).toEqual(start);
});
it('validates capability-gated interactions with a typed error, not a silent no-op', async () => {
const adapter = createAdapter();
const descriptor = await adapter.describe(CONTEXT);
const catalog = await adapter.catalog(CONTEXT);
const selection = toSelection(pickAvailable(catalog.models));
const handle = await adapter.create({
context: CONTEXT,
conversationId: 'conv-interaction',
selection,
});
const response = {
requestId: 'interaction-1',
type: 'confirm',
accepted: true,
} as const;
if (descriptor.capabilities.includes('extensionUi')) {
await expect(handle.respondInteraction(response)).resolves.toBeUndefined();
} else {
const error = await captureError(() => handle.respondInteraction(response));
expect(error).toBeInstanceOf(HarnessOperationError);
expect((error as HarnessOperationError).dto.code).toBe('interaction_unsupported');
}
});
});
}
@@ -39,7 +39,6 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
| TypeScript strict typing | `guides/TYPESCRIPT.md` | | TypeScript strict typing | `guides/TYPESCRIPT.md` |
| QA / test strategy | `guides/QA-TESTING.md` | | QA / test strategy | `guides/QA-TESTING.md` |
| Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` | | Documentation (any code/API/auth/infra change) | `guides/DOCUMENTATION.md` |
| Writing style (docs, comms, any prose) | `guides/WRITING-STYLE.md` |
| Secrets / vault usage | `guides/VAULT-SECRETS.md` | | Secrets / vault usage | `guides/VAULT-SECRETS.md` |
| Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` | | Tool/credential reference (service CLIs, wrappers) | `guides/TOOLS-REFERENCE.md` |
| Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` | | Memory protocol (OpenBrain capture/recall) | `guides/MEMORY.md` |
@@ -27,14 +27,6 @@ Master/slave model:
- Do not perform destructive git/file actions without explicit instruction. - Do not perform destructive git/file actions without explicit instruction.
- Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session. - Browser automation (Playwright, Cypress, Puppeteer) MUST run in headless mode. Never launch a visible browser — it collides with the user's display and active session.
### Output standards (writing + code)
- Technical documentation follows **MOS-STE** (Mosaic Simplified Technical English — an adapted ASD-STE100 profile): short sentences, one instruction per sentence, active voice, one word per meaning, one term per concept. Full rules: `~/.config/mosaic/guides/WRITING-STYLE.md`.
- Apply MOS-STE **hardest to verification artifacts** (acceptance criteria, witness predicates, gate/alarm conditions). There an ambiguous term produces a false green, not just a confused reader.
- Source code follows the **Google Style Guide** for the language.
- User-facing comms follow the user's declared `communicationStyle` in `USER.md` "Communication Preferences" (`direct` | `friendly` | `formal`, default `direct`); `guides/WRITING-STYLE.md` §5 maps each value to output. The documentation standard does not change with user preference.
- **Carve-out:** MOS-STE does NOT apply to content that must carry a specific human voice (letters, personal or marketing prose, voice-matched output). A declared voice profile wins.
### Secrets handling (HARD RULE) ### Secrets handling (HARD RULE)
- Vault is the canonical source-of-truth for every secret in every environment. No exceptions. - Vault is the canonical source-of-truth for every secret in every environment. No exceptions.
@@ -1,134 +0,0 @@
# Writing Style Standard — MOS-STE (MANDATORY)
This guide defines how agents write. It sets one style standard per output type.
It is written in the standard it defines, as a worked example.
**Adapted, not compliant.** MOS-STE (Mosaic Simplified Technical English) is an
adapted profile of ASD-STE100. Mosaic does not license or certify against
ASD-STE100. Mosaic uses the load-bearing rules and fits them to agent work. This
is the same stance Mosaic takes toward DO-178B/C: use the rigor, do not claim the
certification.
## Scope — which standard governs which output
| Output type | Standard |
| ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| Technical documentation (READMEs, runbooks, PRDs, procedures, ADRs, guides, acceptance criteria, design docs) | **MOS-STE** (this guide) |
| Source code and code comments | **Google Style Guide** for the language (§4) |
| Inter-agent comms | MOS-STE by default (concise, structured) |
| User-facing comms | **Per-user style choice** — read `USER.md` "Communication Preferences" (§5) |
| End-user prose the user owns (marketing, letters, personal writing, voice-matched content) | The user's declared voice. MOS-STE does NOT apply. |
**The user-voice carve-out is absolute.** Do not apply MOS-STE to content that
must carry a specific human voice (for example a cover letter, a personal
message, or marketing copy). That content needs the user's voice. MOS-STE would
damage it. When a project declares a voice profile, that profile wins.
## 1. Why one standard
Agent documentation drifts across projects. Different agents use different terms,
sentence styles, and structures for the same concept. Readers lose time.
Assumptions hide in ambiguous prose. One standard gives agents a clear target. It
gives reviewers a clear test.
## 2. Where MOS-STE matters most — verification artifacts
Apply MOS-STE hardest to acceptance criteria, witness predicates, gate
definitions, and alarm conditions. In prose, an ambiguous term produces a
confused reader. In a verification artifact, an ambiguous term produces a false
green — a check that passes without testing the claim.
The one-term-one-concept rule (rule 9) is the guard. When one word names two
concepts in one predicate, the check can test the wrong concept and still pass.
**Worked failure.** A rename used a witness predicate with three clauses: ref A
present, ref B absent, tip committed from this host. Every clause tested the git
_ref_ (the channel). The claim under test was about a _field inside the payload_.
The word "beacon" named two concepts in one sentence. Deleting ref B was the next
scheduled step. That step flips the last clause green and certifies a state in
which the payload still names the wrong host. The predicate was one planned action
away from a false green on its normal path. The payload field was never tested.
Rule: when N failure modes share one observable, the observable is not a
diagnostic. In a verification artifact, that ambiguity does not confuse a reader —
it certifies the defect.
## 3. MOS-STE rules
### 3.1 Sentence rules
1. Keep sentences short. Use 20 words or fewer for a procedure. Use 25 words or
fewer for a description. (Reasoning and doctrine prose relaxes this limit —
see §3.4. A future lint enforces §3.1, not §3.4.)
2. Write one instruction per sentence. In a procedure, give one command per step.
3. Use the active voice. Write "Run the script." Do not write "The script should
be run."
4. Use the imperative for instructions. Start the sentence with the verb.
5. Use simple verb tenses. Prefer the present tense. Avoid the perfect and
progressive tenses when a simple tense works.
6. Do not use an `-ing` form when it makes the meaning unclear.
7. Write positive statements. State what to do, not only what to avoid.
### 3.2 Word rules
8. Use one word for one meaning. Do not use the same word in two senses.
9. Use one term for one concept. Do not use synonyms for variety. Example: choose
`secret`, `credential`, or `key` for each concept, and keep it.
10. Use articles (`a`, `the`). Do not drop words to save space.
11. Keep an approved-terms glossary per project. Add each domain noun and each
chosen verb. Technical names (for example `Vault`, `cgroup`, `systemd`) are
always allowed.
12. Define an abbreviation at its first use. Then use it consistently.
### 3.3 Structure rules
13. Use a list for parallel items or sequential steps. Do not put them in one long
sentence.
14. Use a table for data with more than two dimensions.
15. Use parallel structure in headings and steps.
16. Repeat the noun. Do not use a pronoun when the reference is unclear.
### 3.4 Adaptation notes (where MOS-STE deviates from ASD-STE100, and why)
- **No licensed dictionary.** ASD-STE100 ships a controlled dictionary under
copyright. MOS-STE uses per-project glossaries instead (rule 11).
- **Domain terms are allowed.** MOS-STE keeps every term the work needs.
- **Reasoning prose gets structure, not amputation.** Apply the sentence and word
rules to design and doctrine writing. Allow the length a subtle argument needs.
Readable-first beats rule-strict when the two conflict.
## 4. Code — Google Style Guide
Write source code to the Google Style Guide for the language (Python, TypeScript,
Shell, Go, and so on). Match the existing file when a local convention already
exists. Keep code comments to the MOS-STE sentence and word rules.
## 5. User-facing comms — a per-user choice
Mosaic is multi-user. Different users want different comms styles. The framework
already carries the selectable setting: `communicationStyle` (`direct` |
`friendly` | `formal`, default `direct`). `mosaic init` writes it, and the
builder renders it into the generated `USER.md` "Communication Preferences"
section. This guide adds the OUTPUT meaning of each value; do not invent new
values.
The builder renders the style as prose bullets, not the token name, so match on
the leading bullet the generated `USER.md` actually contains:
| `USER.md` leading bullet | Style | User-facing output |
| ----------------------------- | ------------------ | ---------------------------------------------------------------------- |
| "Direct and concise" | `direct` (default) | MOS-STE structure — short, active, defined terms, tables for overview. |
| "Warm and conversational" | `friendly` | Warmer register. Full sentences, explain reasoning, fewer tables. |
| "Professional and structured" | `formal` | Professional and structured. Thorough, with explicit recommendations. |
This setting governs **user-facing comms only**. It does not change the
documentation standard (§3), which is always MOS-STE regardless of the value.
## 6. Enforcement
- **Now:** human review only. **No mechanical prose check exists today.** The
pre-push gate runs typecheck, lint, build, and tests; it inspects no prose.
Reviewers check output against the scope table and the MOS-STE rules by hand.
- **Future:** an MOS-STE lint check (built from the §3.1 sentence rules) and a
Google-style linter in the pre-push gate. A future linter enforces §3.1, not
§3.4 — see the note at rule 1.
@@ -0,0 +1,317 @@
import { describe, expect, expectTypeOf, it } from 'vitest';
import { HARNESS_CAPABILITIES, HARNESS_ERROR_CODES } from './index.js';
import type {
AttachConversation,
ConversationSnapshot,
CreateHarnessSession,
HarnessAdapter,
HarnessCapability,
HarnessConversationService,
HarnessDescriptor,
HarnessError,
HarnessErrorCode,
HarnessEvent,
HarnessEventEnvelope,
HarnessInteractionState,
HarnessPrompt,
HarnessPromptReceipt,
HarnessSelection,
HarnessSessionHandle,
HarnessSessionSnapshot,
ResumeHarnessSession,
SendHarnessTurn,
TurnReceipt,
} from '../index.js';
const EXPECTED_CAPABILITIES = [
'modelSelection',
'thinkingLevels',
'images',
'toolEvents',
'extensionUi',
'steering',
'followUp',
'compaction',
'persistentResume',
] as const satisfies readonly HarnessCapability[];
const EXPECTED_ERROR_CODES = [
'auth_required',
'selection_invalid',
'catalog_unavailable',
'catalog_stale',
'model_unavailable',
'no_viable_provider',
'session_create_failed',
'session_not_found',
'resume_conflict',
'session_busy',
'auth_bundle_concurrency_unverified',
'adapter_unavailable',
'sandbox_unavailable',
'rpc_version_unsupported',
'rpc_protocol_error',
'process_exited',
'outcome_unknown',
'interaction_unsupported',
'aborted',
] as const satisfies readonly HarnessErrorCode[];
function assertNever(value: never): never {
throw new Error(`Unexpected contract variant: ${JSON.stringify(value)}`);
}
function describeEvent(event: HarnessEvent): string {
switch (event.type) {
case 'session.started':
case 'session.state':
case 'session.identity_changed':
case 'turn.started':
case 'text.delta':
case 'thinking.delta':
case 'tool.started':
case 'tool.updated':
case 'tool.finished':
case 'interaction.required':
case 'usage.updated':
case 'turn.completed':
case 'error':
return event.type;
default:
return assertNever(event);
}
}
function describeError(error: HarnessError): HarnessErrorCode {
switch (error.code) {
case 'auth_required':
case 'selection_invalid':
case 'catalog_unavailable':
case 'catalog_stale':
case 'model_unavailable':
case 'no_viable_provider':
case 'session_create_failed':
case 'session_not_found':
case 'resume_conflict':
case 'session_busy':
case 'auth_bundle_concurrency_unverified':
case 'adapter_unavailable':
case 'sandbox_unavailable':
case 'rpc_version_unsupported':
case 'rpc_protocol_error':
case 'process_exited':
case 'outcome_unknown':
case 'interaction_unsupported':
case 'aborted':
return error.code;
default:
return assertNever(error);
}
}
describe('generic harness contracts', (): void => {
it('keeps harness, provider, model, conversation, native session, process, and seat separate', (): void => {
const selection = {
harnessId: 'pi',
providerId: 'openai-codex',
modelId: 'gpt-5-codex',
} satisfies HarnessSelection;
const snapshot = {
conversationId: 'conversation-1',
nativeSessionId: 'native-session-1',
processId: 'process-1',
seatId: 'seat-1',
selection,
state: 'idle',
attachedClientIds: ['browser-1'],
} satisfies HarnessSessionSnapshot;
const identifiers = [
snapshot.selection.harnessId,
snapshot.selection.providerId,
snapshot.selection.modelId,
snapshot.conversationId,
snapshot.nativeSessionId,
snapshot.processId,
snapshot.seatId,
];
expect(new Set(identifiers).size).toBe(7);
expect(snapshot).toMatchObject({
conversationId: 'conversation-1',
nativeSessionId: 'native-session-1',
processId: 'process-1',
seatId: 'seat-1',
selection,
});
});
it('advertises the complete capability set as checked literals', (): void => {
const descriptor = {
id: 'pi',
displayName: 'Pi',
capabilities: HARNESS_CAPABILITIES,
} satisfies HarnessDescriptor;
expect(HARNESS_CAPABILITIES).toEqual(EXPECTED_CAPABILITIES);
expect(descriptor.capabilities).toEqual(EXPECTED_CAPABILITIES);
});
it('exposes every stable error code as an exhaustive discriminated union', (): void => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'openai-codex',
modelId: 'gpt-5-codex',
};
const error: HarnessError = {
code: 'model_unavailable',
message: 'The selected model is unavailable.',
retryable: true,
correlationId: 'correlation-1',
selection,
};
expect(HARNESS_ERROR_CODES).toEqual(EXPECTED_ERROR_CODES);
expect(describeError(error)).toBe('model_unavailable');
const receipt = {
conversationId: 'conversation-1',
turnId: 'turn-1',
correlationId: 'correlation-1',
state: 'accepted',
selection,
} satisfies HarnessPromptReceipt;
expect(error.selection).toEqual(selection);
expect(receipt.selection).toEqual(selection);
expect('effectiveSelection' in error).toBe(false);
expect('effectiveSelection' in receipt).toBe(false);
});
it('wraps every normalized event variant in the persisted envelope', (): void => {
const selection: HarnessSelection = {
harnessId: 'pi',
providerId: 'openai-codex',
modelId: 'gpt-5-codex',
};
const toolStarted: HarnessEvent = {
type: 'tool.started',
toolCallId: 'tool-call-1',
toolName: 'read',
};
const events: readonly HarnessEvent[] = [
{ type: 'session.started', state: 'idle' },
{ type: 'session.state', state: 'busy' },
{
type: 'session.identity_changed',
identityGeneration: 2,
label: 'Re-enrolled account',
},
{ type: 'turn.started' },
{ type: 'text.delta', text: 'Hello' },
{ type: 'thinking.delta', text: 'Reasoning' },
toolStarted,
{
type: 'tool.updated',
toolCallId: 'tool-call-1',
toolName: 'read',
message: 'Reading',
},
{
type: 'tool.finished',
toolCallId: 'tool-call-1',
toolName: 'read',
isError: false,
},
{
type: 'interaction.required',
requestId: 'interaction-1',
interactionType: 'confirm',
state: 'pending',
prompt: 'Continue?',
},
{
type: 'usage.updated',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
},
{ type: 'turn.completed', outcome: 'settled' },
{
type: 'error',
error: {
code: 'rpc_protocol_error',
message: 'The harness protocol failed.',
retryable: false,
correlationId: 'correlation-1',
selection,
},
},
];
const envelope: HarnessEventEnvelope = {
conversationId: 'conversation-1',
nativeSessionId: 'native-session-1',
turnId: 'turn-1',
correlationId: 'correlation-1',
sequence: 42,
nativeEntryCursor: 'native-entry-7',
occurredAt: '2026-08-11T12:00:00.000Z',
harnessId: 'pi',
selection,
event: toolStarted,
};
expect(events.map(describeEvent)).toEqual([
'session.started',
'session.state',
'session.identity_changed',
'turn.started',
'text.delta',
'thinking.delta',
'tool.started',
'tool.updated',
'tool.finished',
'interaction.required',
'usage.updated',
'turn.completed',
'error',
]);
expect(envelope).toMatchObject({
conversationId: 'conversation-1',
nativeSessionId: 'native-session-1',
turnId: 'turn-1',
correlationId: 'correlation-1',
sequence: 42,
nativeEntryCursor: 'native-entry-7',
harnessId: 'pi',
selection,
event: toolStarted,
});
});
it('models the complete one-response interaction lifecycle', (): void => {
const states = [
'pending',
'responded',
'cancelled',
'expired',
] as const satisfies readonly HarnessInteractionState[];
expect(states).toEqual(['pending', 'responded', 'cancelled', 'expired']);
});
it('preserves the approved adapter and conversation method signatures', (): void => {
expectTypeOf<HarnessAdapter['create']>().toEqualTypeOf<
(input: CreateHarnessSession) => Promise<HarnessSessionHandle>
>();
expectTypeOf<HarnessAdapter['resume']>().toEqualTypeOf<
(input: ResumeHarnessSession) => Promise<HarnessSessionHandle>
>();
expectTypeOf<HarnessSessionHandle['prompt']>().toEqualTypeOf<
(input: HarnessPrompt & { idempotencyKey: string }) => Promise<HarnessPromptReceipt>
>();
expectTypeOf<HarnessConversationService['attach']>().toEqualTypeOf<
(input: AttachConversation & { afterSequence?: number }) => Promise<ConversationSnapshot>
>();
expectTypeOf<HarnessConversationService['send']>().toEqualTypeOf<
(input: SendHarnessTurn & { idempotencyKey: string }) => Promise<TurnReceipt>
>();
});
});
+204
View File
@@ -0,0 +1,204 @@
import type { HarnessCapability, HarnessEvent, HarnessEventEnvelope } from './events.js';
/** Server-derived actor and seat authority. Browser input must not supply these values. */
export interface HarnessActorContext {
readonly actorId: string;
readonly tenantId: string;
readonly seatId: string;
readonly correlationId: string;
}
/** Exact harness/provider/model tuple. These concepts must never be merged into one identifier. */
export interface HarnessSelection {
readonly harnessId: string;
readonly providerId: string;
readonly modelId: string;
}
export interface HarnessDescriptor {
readonly id: string;
readonly displayName: string;
readonly capabilities: readonly HarnessCapability[];
}
export type HarnessInputType = 'text' | 'image';
export type HarnessAuthState = 'ready' | 'auth_required' | 'unavailable';
export type HarnessModelAvailability = 'available' | 'unavailable';
export interface HarnessCatalogEntry extends HarnessSelection {
readonly displayName: string;
readonly reasoningCapability: boolean;
readonly thinkingLevels?: readonly string[];
readonly inputTypes: readonly HarnessInputType[];
readonly contextWindow?: number;
readonly authState: HarnessAuthState;
readonly availability: HarnessModelAvailability;
}
export interface HarnessCatalog {
readonly harnessId: string;
readonly version: string;
readonly fingerprint: string;
readonly models: readonly HarnessCatalogEntry[];
}
export interface CreateHarnessSession {
readonly context: HarnessActorContext;
readonly conversationId: string;
readonly selection: HarnessSelection;
}
export interface ResumeHarnessSession {
readonly context: HarnessActorContext;
readonly conversationId: string;
readonly nativeSessionId: string;
readonly selection: HarnessSelection;
}
export type HarnessSessionState = 'starting' | 'idle' | 'busy' | 'evicted' | 'ended' | 'failed';
export interface HarnessSessionSnapshot {
readonly conversationId: string;
readonly nativeSessionId: string;
/** Absent when the resumable native session has no active process. */
readonly processId?: string;
readonly seatId: string;
readonly selection: HarnessSelection;
readonly state: HarnessSessionState;
readonly attachedClientIds: readonly string[];
}
export interface AttachClient {
readonly clientId: string;
}
export interface HarnessPrompt {
readonly turnId: string;
readonly correlationId: string;
readonly content: string;
}
export type HarnessTurnState =
| 'prepared'
| 'dispatching'
| 'accepted'
| 'streaming'
| 'settled'
| 'failed'
| 'aborted'
| 'interrupted'
| 'outcome_unknown';
/** A successful receipt reports only the selected tuple; no substitute tuple is representable. */
export interface HarnessPromptReceipt {
readonly conversationId: string;
readonly turnId: string;
readonly correlationId: string;
readonly state: HarnessTurnState;
readonly selection: HarnessSelection;
}
export interface HarnessConfirmInteractionResponse {
readonly requestId: string;
readonly type: 'confirm';
readonly accepted: boolean;
}
export interface HarnessSelectInteractionResponse {
readonly requestId: string;
readonly type: 'select';
readonly value: string;
}
export interface HarnessInputInteractionResponse {
readonly requestId: string;
readonly type: 'input';
readonly value: string;
}
export interface HarnessEditorInteractionResponse {
readonly requestId: string;
readonly type: 'editor';
readonly value: string;
}
export interface HarnessCancelInteractionResponse {
readonly requestId: string;
readonly type: 'cancel';
}
export type HarnessInteractionResponse =
| HarnessConfirmInteractionResponse
| HarnessSelectInteractionResponse
| HarnessInputInteractionResponse
| HarnessEditorInteractionResponse
| HarnessCancelInteractionResponse;
export type HarnessCloseReason =
| 'client_request'
| 'idle_timeout'
| 'gateway_shutdown'
| 'process_crash'
| 'composition_changed'
| 'session_ended';
export interface AttachConversation {
readonly context: HarnessActorContext;
readonly conversationId: string;
readonly clientId: string;
readonly selection: HarnessSelection;
}
export interface ConversationSnapshot {
readonly session: HarnessSessionSnapshot;
readonly lastSequence: number;
/** Journal rows replayed after the caller's sequence, never best-effort socket history. */
readonly replay: readonly HarnessEventEnvelope[];
}
export interface DetachConversation {
readonly context: HarnessActorContext;
readonly conversationId: string;
readonly clientId: string;
}
export interface SendHarnessTurn extends HarnessPrompt {
readonly context: HarnessActorContext;
readonly conversationId: string;
readonly selection: HarnessSelection;
}
export interface TurnReceipt extends HarnessPromptReceipt {}
export interface HarnessAdapter {
readonly id: string;
describe(context: HarnessActorContext): Promise<HarnessDescriptor>;
catalog(context: HarnessActorContext): Promise<HarnessCatalog>;
create(input: CreateHarnessSession): Promise<HarnessSessionHandle>;
resume(input: ResumeHarnessSession): Promise<HarnessSessionHandle>;
}
export interface HarnessSessionHandle {
snapshot(): Promise<HarnessSessionSnapshot>;
attach(input: AttachClient): Promise<void>;
/** Removes a browser attachment; it does not terminate the process or native session. */
detach(clientId: string): Promise<void>;
prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise<HarnessPromptReceipt>;
setModel(selection: HarnessSelection): Promise<HarnessSelection>;
abort(turnId: string): Promise<void>;
respondInteraction(input: HarnessInteractionResponse): Promise<void>;
events(listener: (event: HarnessEvent) => void): () => void;
/** Stops the active process while retaining the resumable native session. */
evictProcess(reason: HarnessCloseReason): Promise<void>;
/** Explicitly and destructively ends the native session. */
endSession(reason: HarnessCloseReason): Promise<void>;
}
export interface HarnessConversationService {
attach(input: AttachConversation & { afterSequence?: number }): Promise<ConversationSnapshot>;
/** Removes only the browser attachment represented by the input. */
detach(input: DetachConversation): Promise<void>;
send(input: SendHarnessTurn & { idempotencyKey: string }): Promise<TurnReceipt>;
/** Replays persisted Gateway journal rows after the supplied monotonic sequence. */
subscribeFrom(conversationId: string, afterSequence: number): AsyncIterable<HarnessEventEnvelope>;
}
+40
View File
@@ -0,0 +1,40 @@
import type { HarnessSelection } from './contracts.js';
export const HARNESS_ERROR_CODES = [
'auth_required',
'selection_invalid',
'catalog_unavailable',
'catalog_stale',
'model_unavailable',
'no_viable_provider',
'session_create_failed',
'session_not_found',
'resume_conflict',
'session_busy',
'auth_bundle_concurrency_unverified',
'adapter_unavailable',
'sandbox_unavailable',
'rpc_version_unsupported',
'rpc_protocol_error',
'process_exited',
'outcome_unknown',
'interaction_unsupported',
'aborted',
] as const satisfies readonly string[];
export type HarnessErrorCode = (typeof HARNESS_ERROR_CODES)[number];
export interface HarnessErrorDto<Code extends HarnessErrorCode = HarnessErrorCode> {
readonly code: Code;
/** Safe for browser and operator-facing surfaces. */
readonly message: string;
readonly retryable: boolean;
readonly correlationId: string;
/** The requested selection; errors never report a substituted effective selection. */
readonly selection: HarnessSelection;
}
/** Closed discriminated union over every stable harness error code. */
export type HarnessError = {
readonly [Code in HarnessErrorCode]: HarnessErrorDto<Code>;
}[HarnessErrorCode];
+139
View File
@@ -0,0 +1,139 @@
import type { HarnessError } from './errors.js';
import type { HarnessSelection, HarnessSessionState } from './contracts.js';
export const HARNESS_CAPABILITIES = [
'modelSelection',
'thinkingLevels',
'images',
'toolEvents',
'extensionUi',
'steering',
'followUp',
'compaction',
'persistentResume',
] as const satisfies readonly string[];
export type HarnessCapability = (typeof HARNESS_CAPABILITIES)[number];
/** Durable lifecycle states; later persistence enforces one terminal response per request. */
export type HarnessInteractionState = 'pending' | 'responded' | 'cancelled' | 'expired';
export type HarnessInteractionType = 'confirm' | 'select' | 'input' | 'editor';
export interface HarnessUsage {
readonly inputTokens: number;
readonly outputTokens: number;
readonly totalTokens: number;
}
export type HarnessTurnOutcome =
| 'settled'
| 'failed'
| 'aborted'
| 'interrupted'
| 'outcome_unknown';
export interface HarnessSessionStartedEvent {
readonly type: 'session.started';
readonly state: HarnessSessionState;
}
export interface HarnessSessionStateEvent {
readonly type: 'session.state';
readonly state: HarnessSessionState;
}
export interface HarnessSessionIdentityChangedEvent {
readonly type: 'session.identity_changed';
readonly identityGeneration: number;
readonly label: string;
}
export interface HarnessTurnStartedEvent {
readonly type: 'turn.started';
}
export interface HarnessTextDeltaEvent {
readonly type: 'text.delta';
readonly text: string;
}
export interface HarnessThinkingDeltaEvent {
readonly type: 'thinking.delta';
readonly text: string;
}
export interface HarnessToolStartedEvent {
readonly type: 'tool.started';
readonly toolCallId: string;
readonly toolName: string;
}
export interface HarnessToolUpdatedEvent {
readonly type: 'tool.updated';
readonly toolCallId: string;
readonly toolName: string;
readonly message: string;
}
export interface HarnessToolFinishedEvent {
readonly type: 'tool.finished';
readonly toolCallId: string;
readonly toolName: string;
readonly isError: boolean;
}
export interface HarnessInteractionRequiredEvent {
readonly type: 'interaction.required';
readonly requestId: string;
readonly interactionType: HarnessInteractionType;
readonly state: HarnessInteractionState;
readonly prompt: string;
readonly options?: readonly string[];
}
export interface HarnessUsageUpdatedEvent {
readonly type: 'usage.updated';
readonly usage: HarnessUsage;
}
export interface HarnessTurnCompletedEvent {
readonly type: 'turn.completed';
readonly outcome: HarnessTurnOutcome;
}
export interface HarnessErrorEvent {
readonly type: 'error';
readonly error: HarnessError;
}
export type HarnessEvent =
| HarnessSessionStartedEvent
| HarnessSessionStateEvent
| HarnessSessionIdentityChangedEvent
| HarnessTurnStartedEvent
| HarnessTextDeltaEvent
| HarnessThinkingDeltaEvent
| HarnessToolStartedEvent
| HarnessToolUpdatedEvent
| HarnessToolFinishedEvent
| HarnessInteractionRequiredEvent
| HarnessUsageUpdatedEvent
| HarnessTurnCompletedEvent
| HarnessErrorEvent;
/** Persisted normalized event plus Gateway-owned ordering and native reconciliation metadata. */
export interface HarnessEventEnvelope {
readonly conversationId: string;
readonly nativeSessionId: string;
readonly turnId?: string;
readonly correlationId: string;
/** Monotonic Gateway journal sequence within the conversation. */
readonly sequence: number;
/** Native session-entry cursor when the harness provides one. */
readonly nativeEntryCursor?: string;
readonly occurredAt: string;
readonly harnessId: string;
/** Exact effective selected provider/model tuple; no alternate success selection is exposed. */
readonly selection: HarnessSelection;
readonly event: HarnessEvent;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './contracts.js';
export * from './events.js';
export * from './errors.js';
+1
View File
@@ -8,3 +8,4 @@ export * from './routing/index.js';
export * from './commands/index.js'; export * from './commands/index.js';
export * from './federation/index.js'; export * from './federation/index.js';
export * from './reflection/index.js'; export * from './reflection/index.js';
export * from './harness/index.js';