feat(gateway): register Hermes runtime provider
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
import { AgentRuntimeProviderRegistry, HermesRuntimeProvider } from '@mosaicstack/agent';
|
||||||
import { AgentService } from './agent.service.js';
|
import { AgentService } from './agent.service.js';
|
||||||
import { ProviderService } from './provider.service.js';
|
import { ProviderService } from './provider.service.js';
|
||||||
import { ProviderCredentialsService } from './provider-credentials.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 { LogModule } from '../log/log.module.js';
|
||||||
import { CommandsModule } from '../commands/commands.module.js';
|
import { CommandsModule } from '../commands/commands.module.js';
|
||||||
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
import { CommandRuntimeApprovalVerifier } from '../commands/runtime-approval-verifier.js';
|
||||||
|
import { GatewayHermesRuntimeTransport } from './hermes-runtime.transport.js';
|
||||||
import {
|
import {
|
||||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
RUNTIME_APPROVAL_VERIFIER,
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
@@ -28,6 +29,12 @@ import {
|
|||||||
RuntimeProviderService,
|
RuntimeProviderService,
|
||||||
} from './runtime-provider-registry.service.js';
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
export function createGatewayRuntimeProviderRegistry(): AgentRuntimeProviderRegistry {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
registry.register(new HermesRuntimeProvider(new GatewayHermesRuntimeTransport()));
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
imports: [CoordModule, McpClientModule, SkillsModule, GCModule, LogModule, CommandsModule],
|
||||||
@@ -41,7 +48,7 @@ import {
|
|||||||
TessDurableSessionService,
|
TessDurableSessionService,
|
||||||
{
|
{
|
||||||
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
useFactory: createGatewayRuntimeProviderRegistry,
|
||||||
},
|
},
|
||||||
RuntimeProviderAuditService,
|
RuntimeProviderAuditService,
|
||||||
{
|
{
|
||||||
|
|||||||
46
apps/gateway/src/agent/hermes-runtime.transport.test.ts
Normal file
46
apps/gateway/src/agent/hermes-runtime.transport.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
121
apps/gateway/src/agent/hermes-runtime.transport.ts
Normal file
121
apps/gateway/src/agent/hermes-runtime.transport.ts
Normal file
@@ -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<string[]> {
|
||||||
|
return this.request<string[]>('/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<HermesLegacySession[]> {
|
||||||
|
return this.request<HermesLegacySession[]>('/sessions', scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async *stream(
|
||||||
|
sessionId: string,
|
||||||
|
cursor: string | undefined,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
const params = new URLSearchParams(cursor ? { cursor } : {});
|
||||||
|
const events = await this.request<RuntimeStreamEvent[]>(
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}/stream?${params.toString()}`,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
yield* events;
|
||||||
|
}
|
||||||
|
|
||||||
|
async send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void> {
|
||||||
|
await this.request(`/sessions/${encodeURIComponent(sessionId)}/messages`, scope, {
|
||||||
|
method: 'POST',
|
||||||
|
body: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return this.request<RuntimeAttachHandle>(
|
||||||
|
`/sessions/${encodeURIComponent(sessionId)}/attach`,
|
||||||
|
scope,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: { mode },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
await this.request(`/attachments/${encodeURIComponent(attachmentId)}`, scope, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
await this.request(`/sessions/${encodeURIComponent(sessionId)}/terminate`, scope, {
|
||||||
|
method: 'POST',
|
||||||
|
body: { approvalRef },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
path: string,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
init: { method?: string; body?: unknown } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { createGatewayRuntimeProviderRegistry } from './agent.module.js';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom } from 'rxjs';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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 { RuntimeApprovalDeniedFilter } from './runtime-approval-denied.filter.js';
|
||||||
import { InteractionController } from './interaction.controller.js';
|
import { InteractionController } from './interaction.controller.js';
|
||||||
|
|
||||||
@@ -39,6 +43,32 @@ describe('InteractionController', (): void => {
|
|||||||
else process.env['MOSAIC_AGENT_NAME'] = prior;
|
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 () => {
|
it('rejects a request without the non-simple correlation header', async () => {
|
||||||
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
process.env['MOSAIC_AGENT_NAME'] = 'Nova';
|
||||||
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
const controller = new InteractionController({ listSessions: vi.fn() } as never, {} as never);
|
||||||
|
|||||||
@@ -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')
|
@Get('tree')
|
||||||
async tree(
|
async tree(
|
||||||
@Param('agentName') agentName: string,
|
@Param('agentName') agentName: string,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import type {
|
|||||||
RuntimeSession,
|
RuntimeSession,
|
||||||
RuntimeSessionTree,
|
RuntimeSessionTree,
|
||||||
RuntimeStreamEvent,
|
RuntimeStreamEvent,
|
||||||
|
TransitionalCapabilityInventoryEntry,
|
||||||
|
TransitionalCapabilityInventoryProvider,
|
||||||
} from '@mosaicstack/types';
|
} from '@mosaicstack/types';
|
||||||
import type { ActorTenantScope } from '../auth/session-scope.js';
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
import { LOG_SERVICE } from '../log/log.tokens.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 =
|
export type RuntimeProviderOperation =
|
||||||
| RuntimeCapability
|
| RuntimeCapability
|
||||||
| 'runtime.capabilities'
|
| 'runtime.capabilities'
|
||||||
| 'runtime.health';
|
| 'runtime.health'
|
||||||
|
| 'runtime.transitional-capabilities';
|
||||||
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
|
|
||||||
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
/** 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<boolean>;
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTransitionalInventoryProvider(
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
): provider is AgentRuntimeProvider & TransitionalCapabilityInventoryProvider {
|
||||||
|
return (
|
||||||
|
typeof (provider as Partial<TransitionalCapabilityInventoryProvider>)
|
||||||
|
.transitionalCapabilityMatrix === 'function'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function configuredAgentName(): string {
|
function configuredAgentName(): string {
|
||||||
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
const agentName = process.env['MOSAIC_AGENT_NAME']?.trim();
|
||||||
if (!agentName) throw new RuntimeApprovalDeniedError();
|
if (!agentName) throw new RuntimeApprovalDeniedError();
|
||||||
@@ -151,6 +163,25 @@ export class RuntimeProviderService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async transitionalCapabilityMatrix(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
||||||
|
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(
|
async listSessions(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
context: RuntimeProviderRequestContext,
|
context: RuntimeProviderRequestContext,
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type RuntimeAuditOperation =
|
|||||||
| 'session.attach'
|
| 'session.attach'
|
||||||
| 'session.terminate'
|
| 'session.terminate'
|
||||||
| 'runtime.capabilities'
|
| 'runtime.capabilities'
|
||||||
| 'runtime.health';
|
| 'runtime.health'
|
||||||
|
| 'runtime.transitional-capabilities';
|
||||||
|
|
||||||
export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
export type RuntimeAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error';
|
export type RuntimeAuditErrorCode = 'policy_denied' | 'provider_error';
|
||||||
|
|||||||
Reference in New Issue
Block a user