feat(gateway): generic harness catalog + selection HTTP surfaces (P3 Slice Zero, Task 3) #1169
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
Reference in New Issue
Block a user