From 43513c28f71bbc461797a9d6670eac2172a10d04 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 11 Aug 2026 19:22:27 -0500 Subject: [PATCH] feat(gateway): add harness registry and fake adapter --- .../src/harness/harness.registry.spec.ts | 69 +++++ apps/gateway/src/harness/harness.registry.ts | 100 ++++++ .../src/harness/harness.service.spec.ts | 227 ++++++++++++++ apps/gateway/src/harness/harness.service.ts | 285 ++++++++++++++++++ apps/gateway/src/harness/harness.tokens.ts | 11 + .../testing/fake-harness.adapter.spec.ts | 107 +++++++ .../harness/testing/fake-harness.adapter.ts | 248 +++++++++++++++ .../testing/harness-adapter.contract.ts | 157 ++++++++++ 8 files changed, 1204 insertions(+) create mode 100644 apps/gateway/src/harness/harness.registry.spec.ts create mode 100644 apps/gateway/src/harness/harness.registry.ts create mode 100644 apps/gateway/src/harness/harness.service.spec.ts create mode 100644 apps/gateway/src/harness/harness.service.ts create mode 100644 apps/gateway/src/harness/harness.tokens.ts create mode 100644 apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts create mode 100644 apps/gateway/src/harness/testing/fake-harness.adapter.ts create mode 100644 apps/gateway/src/harness/testing/harness-adapter.contract.ts diff --git a/apps/gateway/src/harness/harness.registry.spec.ts b/apps/gateway/src/harness/harness.registry.spec.ts new file mode 100644 index 00000000..66b1fb7c --- /dev/null +++ b/apps/gateway/src/harness/harness.registry.spec.ts @@ -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); + }); +}); diff --git a/apps/gateway/src/harness/harness.registry.ts b/apps/gateway/src/harness/harness.registry.ts new file mode 100644 index 00000000..b1e67994 --- /dev/null +++ b/apps/gateway/src/harness/harness.registry.ts @@ -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(); + + 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()]; + } +} diff --git a/apps/gateway/src/harness/harness.service.spec.ts b/apps/gateway/src/harness/harness.service.spec.ts new file mode 100644 index 00000000..ff26e973 --- /dev/null +++ b/apps/gateway/src/harness/harness.service.spec.ts @@ -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 { + 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[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'); + }); +}); diff --git a/apps/gateway/src/harness/harness.service.ts b/apps/gateway/src/harness/harness.service.ts new file mode 100644 index 00000000..a9da7998 --- /dev/null +++ b/apps/gateway/src/harness/harness.service.ts @@ -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(); + + 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'; diff --git a/apps/gateway/src/harness/harness.tokens.ts b/apps/gateway/src/harness/harness.tokens.ts new file mode 100644 index 00000000..bbb4dd65 --- /dev/null +++ b/apps/gateway/src/harness/harness.tokens.ts @@ -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; diff --git a/apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts b/apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts new file mode 100644 index 00000000..7a449e7a --- /dev/null +++ b/apps/gateway/src/harness/testing/fake-harness.adapter.spec.ts @@ -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); + }); +}); diff --git a/apps/gateway/src/harness/testing/fake-harness.adapter.ts b/apps/gateway/src/harness/testing/fake-harness.adapter.ts new file mode 100644 index 00000000..58b3f431 --- /dev/null +++ b/apps/gateway/src/harness/testing/fake-harness.adapter.ts @@ -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(); + 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 { + 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 { + this.attachedClientIds.add(input.clientId); + } + + async detach(clientId: string): Promise { + // Removes the browser attachment only; the process and native session persist. + this.attachedClientIds.delete(clientId); + } + + async prompt(input: HarnessPrompt & { idempotencyKey: string }): Promise { + return { + conversationId: this.conversationId, + turnId: input.turnId, + correlationId: input.correlationId, + state: 'accepted', + selection: this.selection, + }; + } + + async setModel(selection: HarnessSelection): Promise { + 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 { + // No active turn machinery in the fake; abort is a no-op acknowledgement. + } + + async respondInteraction(_input: HarnessInteractionResponse): Promise { + 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 { + // Stop the process but keep the resumable native session. + this.processId = undefined; + this.state = 'evicted'; + } + + async endSession(_reason: HarnessCloseReason): Promise { + // 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 { + return { + id: this.id, + displayName: `Fake harness (${this.id})`, + capabilities: this.capabilities, + }; + } + + async catalog(_context: HarnessActorContext): Promise { + return { + harnessId: this.id, + version: '1.0.0', + fingerprint: `fake-${this.id}-${this.catalogEntries.length}`, + models: this.catalogEntries, + }; + } + + async create(input: CreateHarnessSession): Promise { + 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 { + return new FakeHarnessSessionHandle( + input.conversationId, + input.nativeSessionId, + input.context.seatId, + input.selection, + input.context.correlationId, + this.capabilities, + this.catalogEntries, + ); + } +} diff --git a/apps/gateway/src/harness/testing/harness-adapter.contract.ts b/apps/gateway/src/harness/testing/harness-adapter.contract.ts new file mode 100644 index 00000000..d0769b5b --- /dev/null +++ b/apps/gateway/src/harness/testing/harness-adapter.contract.ts @@ -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): Promise { + 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'); + } + }); + }); +} -- 2.54.0