From 127a69ea11ccc36516c78c2007cbe52fbf63ad30 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Mon, 13 Jul 2026 08:55:06 -0500 Subject: [PATCH] feat(gateway): register Hermes runtime provider --- apps/gateway/src/agent/agent.module.ts | 11 +- .../agent/hermes-runtime.transport.test.ts | 46 +++++++ .../src/agent/hermes-runtime.transport.ts | 121 ++++++++++++++++++ .../src/agent/interaction.controller.test.ts | 32 ++++- .../src/agent/interaction.controller.ts | 14 ++ .../runtime-provider-registry.service.ts | 33 ++++- packages/log/src/runtime-audit.ts | 3 +- 7 files changed, 255 insertions(+), 5 deletions(-) create mode 100644 apps/gateway/src/agent/hermes-runtime.transport.test.ts create mode 100644 apps/gateway/src/agent/hermes-runtime.transport.ts diff --git a/apps/gateway/src/agent/agent.module.ts b/apps/gateway/src/agent/agent.module.ts index f563bcc..0c8b1b6 100644 --- a/apps/gateway/src/agent/agent.module.ts +++ b/apps/gateway/src/agent/agent.module.ts @@ -1,5 +1,5 @@ import { Global, Module } from '@nestjs/common'; -import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent'; +import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent'; import { AgentService } from './agent.service.js'; import { ProviderService } from './provider.service.js'; import { ProviderCredentialsService } from './provider-credentials.service.js'; @@ -20,6 +20,7 @@ import { GCModule } from '../gc/gc.module.js'; import { LogModule } from '../log/log.module.js'; import { CommandsModule } from '../commands/commands.module.js'; import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js'; +import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; import { AGENT_RUNTIME_PROVIDER_REGISTRY, RUNTIME_APPROVAL_VERIFIER, @@ -28,6 +29,12 @@ import { RuntimeProviderService, } from './runtime-provider-registry.service.js'; +export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry { + const registry = new AgentRuntimeProviderRegistry(); + registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport())); + return registry; +} + @Global() @Module({ imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule], @@ -41,7 +48,7 @@ import { TessDurableSessionService, { provide: AGENT_RUNTIME_PROVIDER_REGISTRY, - useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(), + useFactory: createGatewayRuntimeProviderRegistry, }, RuntimeProviderAuditService, { diff --git a/apps/gateway/src/agent/hermes-runtime.transport.test.ts b/apps/gateway/src/agent/hermes-runtime.transport.test.ts new file mode 100644 index 0000000..48e7ff4 --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime.transport.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest'; +import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js'; + +const scope = { + actorId: 'owner-1', + tenantId: 'tenant-1', + channelId: 'cli', + correlationId: 'correlation-1', +}; + +describe('GatewayHermesRuntimeTransport', () => { + it('preserves a configured path prefix and authenticates the concrete runtime request', async () => { + const fetchFn = vi + .fn() + .mockResolvedValue(new Response(JSON.stringify(['session.list']), { status: 200 })); + const transport = new GatewayHermesRuntimeTransport( + 'https://runtime.example.test/hermes', + 'test-service-token', + fetchFn, + ); + + await expect(transport.capabilities(scope)).resolves.toEqual(['session.list']); + + expect(fetchFn).toHaveBeenCalledWith( + new URL('https://runtime.example.test/hermes/capabilities'), + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: 'Bearer test-service-token', + 'x-mosaic-channel-id': 'cli', + }), + }), + ); + }); + + it('rejects non-loopback HTTP runtime endpoints before sending identity headers', async () => { + const fetchFn = vi.fn(); + const transport = new GatewayHermesRuntimeTransport( + 'http://runtime.example.test/hermes', + 'test-service-token', + fetchFn, + ); + + await expect(transport.capabilities(scope)).rejects.toThrow('requires HTTPS'); + expect(fetchFn).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/gateway/src/agent/hermes-runtime.transport.ts b/apps/gateway/src/agent/hermes-runtime.transport.ts new file mode 100644 index 0000000..c095c35 --- /dev/null +++ b/apps/gateway/src/agent/hermes-runtime.transport.ts @@ -0,0 +1,121 @@ +import type { HermesLegacySession, HermesRuntimeTransport } from '@mosaicstack/agent'; +import type { + RuntimeAttachHandle, + RuntimeAttachMode, + RuntimeMessage, + RuntimeScope, + RuntimeStreamEvent, +} from '@mosaicstack/types'; + +/** Concrete HTTP transport for a configured legacy Hermes runtime endpoint. */ +export class GatewayHermesRuntimeTransport implements HermesRuntimeTransport { + constructor( + private readonly baseUrl = process.env['MOSAIC_HERMES_RUNTIME_URL']?.trim(), + private readonly serviceToken = process.env['MOSAIC_HERMES_RUNTIME_TOKEN']?.trim(), + private readonly fetchFn: typeof fetch = fetch, + ) {} + + async capabilities(scope: RuntimeScope): Promise { + return this.request('/capabilities', scope); + } + + async health(scope: RuntimeScope): Promise<{ status: string; detail?: string }> { + return this.request<{ status: string; detail?: string }>('/health', scope); + } + + async sessions(scope: RuntimeScope): Promise { + return this.request('/sessions', scope); + } + + async *stream( + sessionId: string, + cursor: string | undefined, + scope: RuntimeScope, + ): AsyncIterable { + const params = new URLSearchParams(cursor ? { cursor } : {}); + const events = await this.request( + `/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`, + scope, + ); + yield* events; + } + + async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise { + await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, { + method: 'POST', + body: message, + }); + } + + async attach( + sessionId: string, + mode: RuntimeAttachMode, + scope: RuntimeScope, + ): Promise { + return this.request( + `/sessions/${encodeURIComponent(sessionId)}/attach`, + scope, + { + method: 'POST', + body: { mode }, + }, + ); + } + + async detach(attachmentId: string, scope: RuntimeScope): Promise { + await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, { + method: 'DELETE', + }); + } + + async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise { + await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, { + method: 'POST', + body: { approvalRef }, + }); + } + + private async request( + path: string, + scope: RuntimeScope, + init: { method?: string; body?: unknown } = {}, + ): Promise { + if (!this.baseUrl || !this.serviceToken) { + throw new Error( + 'MOSAIC_HERMES_RUNTIME_URL and MOSAIC_HERMES_RUNTIME_TOKEN must configure Hermes transport', + ); + } + const endpoint = new URL(this.baseUrl); + if (endpoint.protocol !== 'https:' && !isLoopbackHttp(endpoint)) { + throw new Error('Hermes runtime transport requires HTTPS outside loopback'); + } + const response = await this.fetchFn( + new URL(path.replace(/^\//, ''), `${endpoint.toString().replace(/\/$/, '')}/`), + { + method: init.method ?? 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${this.serviceToken}`, + 'x-mosaic-actor-id': scope.actorId, + 'x-mosaic-tenant-id': scope.tenantId, + 'x-mosaic-channel-id': scope.channelId, + 'x-correlation-id': scope.correlationId, + ...(init.body ? { 'content-type': 'application/json' } : {}), + }, + ...(init.body ? { body: JSON.stringify(init.body) } : {}), + }, + ); + if (!response.ok) throw new Error(`Hermes runtime request failed: ${response.status}`); + if (response.status === 204) return undefined as T; + return (await response.json()) as T; + } +} + +function isLoopbackHttp(endpoint: URL): boolean { + return ( + endpoint.protocol === 'http:' && + (endpoint.hostname === 'localhost' || + endpoint.hostname === '127.0.0.1' || + endpoint.hostname === '::1') + ); +} diff --git a/apps/gateway/src/agent/interaction.controller.test.ts b/apps/gateway/src/agent/interaction.controller.test.ts index db0dad4..78030a6 100644 --- a/apps/gateway/src/agent/interaction.controller.test.ts +++ b/apps/gateway/src/agent/interaction.controller.test.ts @@ -1,6 +1,10 @@ +import { createGatewayRuntimeProviderRegistry } from './agent.module.js'; import { firstValueFrom } from 'rxjs'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { RuntimeApprovalDeniedError } from './runtime-provider-registry.service.js'; +import { + RuntimeApprovalDeniedError, + RuntimeProviderService, +} from './runtime-provider-registry.service.js'; import { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js'; import { InteractionController } from './interaction.controller.js'; @@ -39,6 +43,32 @@ describe('InteractionController', (): void => { else process.env['MOSAIC_AGENT_NAME'] = prior; }); + it('reaches the registered Hermes provider through the authenticated transitional matrix route', async () => { + process.env['MOSAIC_AGENT_NAME'] = 'Nova'; + const registry = createGatewayRuntimeProviderRegistry(); + const runtime = new RuntimeProviderService( + registry, + { record: vi.fn().mockResolvedValue(undefined) }, + { consume: vi.fn().mockResolvedValue(false) }, + ); + const controller = new InteractionController(runtime, {} as never); + + await expect( + controller.transitionalCapabilities( + 'Nova', + 'runtime.hermes', + { id: 'owner', tenantId: 'team' }, + 'corr-1', + ), + ).resolves.toEqual([ + { capability: 'kanban', status: 'unsupported' }, + { capability: 'skills', status: 'unsupported' }, + { capability: 'memory', status: 'unsupported' }, + { capability: 'tools', status: 'unsupported' }, + { capability: 'cron', status: 'unsupported' }, + ]); + }); + it('rejects a request without the non-simple correlation header', async () => { process.env['MOSAIC_AGENT_NAME'] = 'Nova'; const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never); diff --git a/apps/gateway/src/agent/interaction.controller.ts b/apps/gateway/src/agent/interaction.controller.ts index cc247fd..04b77d5 100644 --- a/apps/gateway/src/agent/interaction.controller.ts +++ b/apps/gateway/src/agent/interaction.controller.ts @@ -51,6 +51,20 @@ export class InteractionController { ); } + @Get('transitional-capabilities') + async transitionalCapabilities( + @Param('agentName') agentName: string, + @Query('provider') providerId: string, + @CurrentUser() user: AuthenticatedUserLike, + @Headers('x-correlation-id') correlationId?: string, + ) { + this.assertConfiguredAgent(agentName); + return this.runtime.transitionalCapabilityMatrix( + this.requiredProvider(providerId), + this.context(user, correlationId), + ); + } + @Get('tree') async tree( @Param('agentName') agentName: string, diff --git a/apps/gateway/src/agent/runtime-provider-registry.service.ts b/apps/gateway/src/agent/runtime-provider-registry.service.ts index b824bb0..e62953d 100644 --- a/apps/gateway/src/agent/runtime-provider-registry.service.ts +++ b/apps/gateway/src/agent/runtime-provider-registry.service.ts @@ -17,6 +17,8 @@ import type { RuntimeSession, RuntimeSessionTree, RuntimeStreamEvent, + TransitionalCapabilityInventoryEntry, + TransitionalCapabilityInventoryProvider, } from '@mosaicstack/types'; import type { ActorTenantScope } from '../auth/session-scope.js'; import { LOG_SERVICE } from '../log/log.tokens.js'; @@ -28,7 +30,8 @@ export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER'); export type RuntimeProviderOperation = | RuntimeCapability | 'runtime.capabilities' - | 'runtime.health'; + | 'runtime.health' + | 'runtime.transitional-capabilities'; export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; /** Trusted server-side context only; it intentionally excludes client-provided identity fields. */ @@ -71,6 +74,15 @@ export interface RuntimeApprovalVerifier { consume(approvalRef: string, action: RuntimeTerminationAction): Promise; } +function isTransitionalInventoryProvider( + provider: AgentRuntimeProvider, +): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider { + return ( + typeof (provider as Partial) + .transitionalCapabilityMatrix === 'function' + ); +} + function configuredAgentName(): string { const agentName = process.env['MOSAIC_AGENT_NAME']?.trim(); if (!agentName) throw new RuntimeApprovalDeniedError(); @@ -151,6 +163,25 @@ export class RuntimeProviderService { ); } + async transitionalCapabilityMatrix( + providerId: string, + context: RuntimeProviderRequestContext, + ): Promise { + return this.execute( + providerId, + 'runtime.transitional-capabilities', + undefined, + undefined, + context, + async (provider: AgentRuntimeProvider, scope: RuntimeScope) => { + if (!isTransitionalInventoryProvider(provider)) { + throw new NotFoundException('Runtime provider has no transitional capability inventory'); + } + return provider.transitionalCapabilityMatrix(scope); + }, + ); + } + async listSessions( providerId: string, context: RuntimeProviderRequestContext, diff --git a/packages/log/src/runtime-audit.ts b/packages/log/src/runtime-audit.ts index 1292329..66bc00f 100644 --- a/packages/log/src/runtime-audit.ts +++ b/packages/log/src/runtime-audit.ts @@ -9,7 +9,8 @@ export type RuntimeAuditOperation = | 'session.attach' | 'session.terminate' | 'runtime.capabilities' - | 'runtime.health'; + | 'runtime.health' + | 'runtime.transitional-capabilities'; export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed'; export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error';