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(); constructor(@Inject(HARNESS_REGISTRY) private readonly registry: HarnessRegistry) {} async createSession( scope: TrustedGatewayScope, request: CreateHarnessSessionRequest, ): Promise { 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 { 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 { 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 { assertTrustedRequest(request); const handle = this.requireHandle(scope, request.conversationId); await handle.detach(request.clientId); return handle.snapshot(); } async evict( scope: TrustedGatewayScope, request: EvictSessionRequest, ): Promise { assertTrustedRequest(request); const handle = this.requireHandle(scope, request.conversationId); await handle.evictProcess(request.reason); return handle.snapshot(); } async end( scope: TrustedGatewayScope, request: EndSessionRequest, ): Promise { 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 { assertTrustedRequest(request); const handle = this.requireHandle(scope, request.conversationId); await handle.respondInteraction(request.response); } async snapshot( scope: TrustedGatewayScope, conversationId: string, ): Promise { 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, selection: HarnessSelection, ): Promise { 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';