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
9 changed files with 649 additions and 0 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,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 {}