feat: add Tess runtime provider registry #722
@@ -31,6 +31,7 @@
|
|||||||
"@mariozechner/pi-ai": "^0.65.0",
|
"@mariozechner/pi-ai": "^0.65.0",
|
||||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
"@mosaicstack/agent": "workspace:^",
|
||||||
"@mosaicstack/auth": "workspace:^",
|
"@mosaicstack/auth": "workspace:^",
|
||||||
"@mosaicstack/brain": "workspace:^",
|
"@mosaicstack/brain": "workspace:^",
|
||||||
"@mosaicstack/config": "workspace:^",
|
"@mosaicstack/config": "workspace:^",
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapability,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import type { ActorTenantScope } from '../../auth/session-scope.js';
|
||||||
|
import {
|
||||||
|
RuntimeProviderService,
|
||||||
|
type RuntimeAuditEvent,
|
||||||
|
type RuntimeAuditSink,
|
||||||
|
type RuntimeApprovalVerifier,
|
||||||
|
} from '../runtime-provider-registry.service.js';
|
||||||
|
|
||||||
|
const OWNER_SCOPE: ActorTenantScope = { userId: 'owner-1', tenantId: 'tenant-1' };
|
||||||
|
const CONTEXT = {
|
||||||
|
actorScope: OWNER_SCOPE,
|
||||||
|
channelId: 'cli',
|
||||||
|
correlationId: 'correlation-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
class RecordingRuntimeProvider implements AgentRuntimeProvider {
|
||||||
|
readonly id = 'fleet';
|
||||||
|
readonly receivedScopes: RuntimeScope[] = [];
|
||||||
|
readonly sentMessages: RuntimeMessage[] = [];
|
||||||
|
terminateCalls = 0;
|
||||||
|
throwAfterSend = false;
|
||||||
|
|
||||||
|
constructor(private readonly supported: RuntimeCapability[]) {}
|
||||||
|
|
||||||
|
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return { supported: this.supported };
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamSession(
|
||||||
|
_sessionId: string,
|
||||||
|
_cursor: string | undefined,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
_sessionId: string,
|
||||||
|
message: RuntimeMessage,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
this.sentMessages.push(message);
|
||||||
|
if (this.throwAfterSend) {
|
||||||
|
throw new Error('provider acknowledgement failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
return {
|
||||||
|
attachmentId: 'attachment-1',
|
||||||
|
sessionId,
|
||||||
|
mode,
|
||||||
|
expiresAt: '2026-07-12T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(_attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(_sessionId: string, _approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||||
|
this.receivedScopes.push(scope);
|
||||||
|
this.terminateCalls += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingAuditSink implements RuntimeAuditSink {
|
||||||
|
readonly events: RuntimeAuditEvent[] = [];
|
||||||
|
|
||||||
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
|
this.events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DenyingApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
async consume(): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AcceptingApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
consumedAction: Parameters<RuntimeApprovalVerifier['consume']>[1] | undefined;
|
||||||
|
|
||||||
|
async consume(
|
||||||
|
_approvalRef: string,
|
||||||
|
action: Parameters<RuntimeApprovalVerifier['consume']>[1],
|
||||||
|
): Promise<boolean> {
|
||||||
|
this.consumedAction = action;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(
|
||||||
|
provider: RecordingRuntimeProvider,
|
||||||
|
audit: RuntimeAuditSink = new RecordingAuditSink(),
|
||||||
|
approval: RuntimeApprovalVerifier = new DenyingApprovalVerifier(),
|
||||||
|
): RuntimeProviderService {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
registry.register(provider);
|
||||||
|
return new RuntimeProviderService(registry, audit, approval);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RuntimeProviderService security boundary', (): void => {
|
||||||
|
it('derives and freezes only the authenticated actor scope while preserving correlation metadata', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const providerScope = provider.receivedScopes[0];
|
||||||
|
expect(providerScope).toEqual({
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(providerScope)).toBe(true);
|
||||||
|
expect(audit.events).toContainEqual({
|
||||||
|
providerId: 'fleet',
|
||||||
|
operation: 'session.send',
|
||||||
|
outcome: 'succeeded',
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
resourceId: 'session-1',
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(audit.events)).not.toContain('hello');
|
||||||
|
expect(JSON.stringify(audit.events)).not.toContain('key-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before a provider side effect when a capability is missing', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider([]);
|
||||||
|
const service = makeService(provider);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/capability denied/);
|
||||||
|
expect(provider.sentMessages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a consumed exact-action approval before termination', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
|
const approval = new DenyingApprovalVerifier();
|
||||||
|
const service = makeService(provider, new RecordingAuditSink(), approval);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
||||||
|
).rejects.toThrow(/approval denied/);
|
||||||
|
expect(provider.terminateCalls).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('binds an accepted termination approval to provider, session, immutable scope, and correlation', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.terminate']);
|
||||||
|
const approval = new AcceptingApprovalVerifier();
|
||||||
|
const service = makeService(provider, new RecordingAuditSink(), approval);
|
||||||
|
|
||||||
|
await service.terminate('fleet', 'session-1', 'approval-1', CONTEXT);
|
||||||
|
|
||||||
|
expect(approval.consumedAction).toEqual({
|
||||||
|
providerId: 'fleet',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
actorId: OWNER_SCOPE.userId,
|
||||||
|
tenantId: OWNER_SCOPE.tenantId,
|
||||||
|
channelId: CONTEXT.channelId,
|
||||||
|
correlationId: CONTEXT.correlationId,
|
||||||
|
});
|
||||||
|
expect(provider.terminateCalls).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails closed before invoking a provider when audit persistence rejects the request', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
const unavailableAudit: RuntimeAuditSink = {
|
||||||
|
async record(): Promise<void> {
|
||||||
|
throw new Error('audit unavailable');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = makeService(provider, unavailableAudit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/audit unavailable/);
|
||||||
|
expect(provider.sentMessages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a provider error after invocation as failed rather than denied', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
provider.throwAfterSend = true;
|
||||||
|
const audit = new RecordingAuditSink();
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/provider acknowledgement failed/);
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(audit.events.map((event: RuntimeAuditEvent): string => event.outcome)).toEqual([
|
||||||
|
'requested',
|
||||||
|
'failed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not misreport a completed provider side effect when completion auditing fails', async (): Promise<void> => {
|
||||||
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
||||||
|
let auditCalls = 0;
|
||||||
|
const audit: RuntimeAuditSink = {
|
||||||
|
async record(): Promise<void> {
|
||||||
|
auditCalls += 1;
|
||||||
|
if (auditCalls === 2) {
|
||||||
|
throw new Error('completion audit unavailable');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const service = makeService(provider, audit);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.sendMessage(
|
||||||
|
'fleet',
|
||||||
|
'session-1',
|
||||||
|
{ content: 'hello', idempotencyKey: 'key-1' },
|
||||||
|
CONTEXT,
|
||||||
|
),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(provider.sentMessages).toHaveLength(1);
|
||||||
|
expect(auditCalls).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { AgentRuntimeProviderRegistry } 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';
|
||||||
@@ -13,6 +14,14 @@ import { CoordModule } from '../coord/coord.module.js';
|
|||||||
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
import { McpClientModule } from '../mcp-client/mcp-client.module.js';
|
||||||
import { SkillsModule } from '../skills/skills.module.js';
|
import { SkillsModule } from '../skills/skills.module.js';
|
||||||
import { GCModule } from '../gc/gc.module.js';
|
import { GCModule } from '../gc/gc.module.js';
|
||||||
|
import {
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
DenyRuntimeApprovalVerifier,
|
||||||
|
RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
} from './runtime-provider-registry.service.js';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
@@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js';
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
{
|
||||||
|
provide: AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
|
useFactory: (): AgentRuntimeProviderRegistry => new AgentRuntimeProviderRegistry(),
|
||||||
|
},
|
||||||
|
RuntimeProviderAuditService,
|
||||||
|
{
|
||||||
|
provide: RUNTIME_PROVIDER_AUDIT_SINK,
|
||||||
|
useExisting: RuntimeProviderAuditService,
|
||||||
|
},
|
||||||
|
DenyRuntimeApprovalVerifier,
|
||||||
|
{
|
||||||
|
provide: RUNTIME_APPROVAL_VERIFIER,
|
||||||
|
useExisting: DenyRuntimeApprovalVerifier,
|
||||||
|
},
|
||||||
|
RuntimeProviderService,
|
||||||
AgentService,
|
AgentService,
|
||||||
],
|
],
|
||||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||||
@@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js';
|
|||||||
RoutingService,
|
RoutingService,
|
||||||
RoutingEngineService,
|
RoutingEngineService,
|
||||||
SkillLoaderService,
|
SkillLoaderService,
|
||||||
|
RuntimeProviderService,
|
||||||
|
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AgentModule {}
|
export class AgentModule {}
|
||||||
|
|||||||
404
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
404
apps/gateway/src/agent/runtime-provider-registry.service.ts
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapability,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import type { ActorTenantScope } from '../auth/session-scope.js';
|
||||||
|
|
||||||
|
export const AGENT_RUNTIME_PROVIDER_REGISTRY = Symbol('AGENT_RUNTIME_PROVIDER_REGISTRY');
|
||||||
|
export const RUNTIME_PROVIDER_AUDIT_SINK = Symbol('RUNTIME_PROVIDER_AUDIT_SINK');
|
||||||
|
export const RUNTIME_APPROVAL_VERIFIER = Symbol('RUNTIME_APPROVAL_VERIFIER');
|
||||||
|
|
||||||
|
export type RuntimeProviderOperation =
|
||||||
|
| RuntimeCapability
|
||||||
|
| 'runtime.capabilities'
|
||||||
|
| 'runtime.health';
|
||||||
|
export type RuntimeProviderAuditOutcome = 'requested' | 'succeeded' | 'denied' | 'failed';
|
||||||
|
|
||||||
|
/** Trusted server-side context only; it intentionally excludes client-provided identity fields. */
|
||||||
|
export interface RuntimeProviderRequestContext {
|
||||||
|
actorScope: ActorTenantScope;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Metadata-only audit record. Message bodies, idempotency keys, and approval refs are excluded. */
|
||||||
|
export interface RuntimeAuditEvent {
|
||||||
|
providerId: string;
|
||||||
|
operation: RuntimeProviderOperation;
|
||||||
|
outcome: RuntimeProviderAuditOutcome;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
resourceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeAuditSink {
|
||||||
|
record(event: RuntimeAuditEvent): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exact action shape that a durable approval implementation must consume once. */
|
||||||
|
export interface RuntimeTerminationAction {
|
||||||
|
providerId: string;
|
||||||
|
sessionId: string;
|
||||||
|
actorId: string;
|
||||||
|
tenantId: string;
|
||||||
|
channelId: string;
|
||||||
|
correlationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeApprovalVerifier {
|
||||||
|
consume(approvalRef: string, action: RuntimeTerminationAction): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class RuntimeApprovalDeniedError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Runtime termination approval denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default denies all runtime termination until a durable, exact-action
|
||||||
|
* approval implementation is configured. This is safer than a permissive stub.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DenyRuntimeApprovalVerifier implements RuntimeApprovalVerifier {
|
||||||
|
async consume(_approvalRef: string, _action: RuntimeTerminationAction): Promise<boolean> {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Temporary metadata-only audit sink. M1 observability can replace this token
|
||||||
|
* with a durable audit writer without changing provider call sites.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RuntimeProviderAuditService implements RuntimeAuditSink {
|
||||||
|
private readonly logger = new Logger(RuntimeProviderAuditService.name);
|
||||||
|
|
||||||
|
async record(event: RuntimeAuditEvent): Promise<void> {
|
||||||
|
this.logger.log(JSON.stringify(event));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RuntimeProviderService {
|
||||||
|
private readonly logger = new Logger(RuntimeProviderService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(AGENT_RUNTIME_PROVIDER_REGISTRY)
|
||||||
|
private readonly registry: AgentRuntimeProviderRegistry,
|
||||||
|
@Inject(RUNTIME_PROVIDER_AUDIT_SINK)
|
||||||
|
private readonly audit: RuntimeAuditSink,
|
||||||
|
@Inject(RUNTIME_APPROVAL_VERIFIER)
|
||||||
|
private readonly approvals: RuntimeApprovalVerifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async capabilities(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeCapabilitySet> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.capabilities',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeCapabilitySet> =>
|
||||||
|
provider.capabilities(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(providerId: string, context: RuntimeProviderRequestContext): Promise<RuntimeHealth> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'runtime.health',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeHealth> =>
|
||||||
|
provider.health(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeSession[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.list',
|
||||||
|
'session.list',
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSession[]> =>
|
||||||
|
provider.listSessions(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(
|
||||||
|
providerId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeSessionTree[]> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.tree',
|
||||||
|
'session.tree',
|
||||||
|
undefined,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeSessionTree[]> =>
|
||||||
|
provider.getSessionTree(scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
streamSession(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
cursor: string | undefined,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
return this.stream(
|
||||||
|
providerId,
|
||||||
|
'session.stream',
|
||||||
|
'session.stream',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): AsyncIterable<RuntimeStreamEvent> =>
|
||||||
|
provider.streamSession(sessionId, cursor, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
message: RuntimeMessage,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.send',
|
||||||
|
'session.send',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||||
|
provider.sendMessage(sessionId, message, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
mode: RuntimeAttachMode,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.attach',
|
||||||
|
'session.attach',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<RuntimeAttachHandle> =>
|
||||||
|
provider.attach(sessionId, mode, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(
|
||||||
|
providerId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.attach',
|
||||||
|
'session.attach',
|
||||||
|
attachmentId,
|
||||||
|
context,
|
||||||
|
(provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> =>
|
||||||
|
provider.detach(attachmentId, scope),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async terminate(
|
||||||
|
providerId: string,
|
||||||
|
sessionId: string,
|
||||||
|
approvalRef: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.execute(
|
||||||
|
providerId,
|
||||||
|
'session.terminate',
|
||||||
|
'session.terminate',
|
||||||
|
sessionId,
|
||||||
|
context,
|
||||||
|
async (provider: AgentRuntimeProvider, scope: RuntimeScope): Promise<void> => {
|
||||||
|
const approved = await this.approvals.consume(approvalRef, {
|
||||||
|
providerId,
|
||||||
|
sessionId,
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
});
|
||||||
|
if (!approved) {
|
||||||
|
throw new RuntimeApprovalDeniedError();
|
||||||
|
}
|
||||||
|
await provider.terminate(sessionId, approvalRef, scope);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute<T>(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
requiredCapability: RuntimeCapability | undefined,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
invoke: (provider: AgentRuntimeProvider, scope: RuntimeScope) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const scope = this.deriveScope(context);
|
||||||
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
|
let invocationStarted = false;
|
||||||
|
try {
|
||||||
|
const provider = this.provider(providerId);
|
||||||
|
if (requiredCapability) {
|
||||||
|
await this.assertCapability(provider, requiredCapability, scope);
|
||||||
|
}
|
||||||
|
invocationStarted = true;
|
||||||
|
const result = await invoke(provider, scope);
|
||||||
|
await this.recordCompletion(providerId, operation, scope, resourceId);
|
||||||
|
return result;
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (invocationStarted && !(error instanceof RuntimeApprovalDeniedError)) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId);
|
||||||
|
} else {
|
||||||
|
await this.record(providerId, operation, 'denied', scope, resourceId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async *stream(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
requiredCapability: RuntimeCapability,
|
||||||
|
resourceId: string,
|
||||||
|
context: RuntimeProviderRequestContext,
|
||||||
|
invoke: (
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
) => AsyncIterable<RuntimeStreamEvent>,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
const scope = this.deriveScope(context);
|
||||||
|
await this.record(providerId, operation, 'requested', scope, resourceId);
|
||||||
|
let invocationStarted = false;
|
||||||
|
try {
|
||||||
|
const provider = this.provider(providerId);
|
||||||
|
await this.assertCapability(provider, requiredCapability, scope);
|
||||||
|
invocationStarted = true;
|
||||||
|
for await (const event of invoke(provider, scope)) {
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
await this.recordCompletion(providerId, operation, scope, resourceId);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (invocationStarted) {
|
||||||
|
await this.recordFailure(providerId, operation, scope, resourceId);
|
||||||
|
} else {
|
||||||
|
await this.record(providerId, operation, 'denied', scope, resourceId);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private provider(providerId: string): AgentRuntimeProvider {
|
||||||
|
try {
|
||||||
|
return this.registry.require(providerId);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Runtime provider is not registered';
|
||||||
|
throw new NotFoundException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCapability(
|
||||||
|
provider: AgentRuntimeProvider,
|
||||||
|
requiredCapability: RuntimeCapability,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
): Promise<void> {
|
||||||
|
const capabilities = await provider.capabilities(scope);
|
||||||
|
if (!capabilities.supported.includes(requiredCapability)) {
|
||||||
|
throw new ForbiddenException(`Runtime provider capability denied: ${requiredCapability}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private deriveScope(context: RuntimeProviderRequestContext): RuntimeScope {
|
||||||
|
const actorId = context.actorScope.userId.trim();
|
||||||
|
const tenantId = context.actorScope.tenantId.trim();
|
||||||
|
const channelId = context.channelId.trim();
|
||||||
|
const correlationId = context.correlationId.trim();
|
||||||
|
if (!actorId || !tenantId || !channelId || !correlationId) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Authenticated runtime actor scope and correlation are required',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ actorId, tenantId, channelId, correlationId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordFailure(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.record(providerId, operation, 'failed', scope, resourceId);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Runtime provider failure audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async recordCompletion(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.record(providerId, operation, 'succeeded', scope, resourceId);
|
||||||
|
} catch {
|
||||||
|
this.logger.error(
|
||||||
|
`Runtime provider completion audit failed provider=${providerId} operation=${operation} correlation=${scope.correlationId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async record(
|
||||||
|
providerId: string,
|
||||||
|
operation: RuntimeProviderOperation,
|
||||||
|
outcome: RuntimeProviderAuditOutcome,
|
||||||
|
scope: RuntimeScope,
|
||||||
|
resourceId: string | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.audit.record({
|
||||||
|
providerId,
|
||||||
|
operation,
|
||||||
|
outcome,
|
||||||
|
actorId: scope.actorId,
|
||||||
|
tenantId: scope.tenantId,
|
||||||
|
channelId: scope.channelId,
|
||||||
|
correlationId: scope.correlationId,
|
||||||
|
...(resourceId ? { resourceId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
38
docs/scratchpads/tess-m1-002-provider-registry.md
Normal file
38
docs/scratchpads/tess-m1-002-provider-registry.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# TESS-M1-002 — Provider Registry
|
||||||
|
|
||||||
|
- **Issue:** #707
|
||||||
|
- **Branch:** `feat/tess-provider-registry`
|
||||||
|
- **Objective:** Build the runtime provider registry/service boundary that derives immutable actor/tenant/channel/correlation scope server-side, fail-closes unsupported and destructive runtime operations, binds termination approval to the exact structured action, and emits correlation-safe audit events.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add a provider-agnostic registry in `@mosaicstack/agent` over the merged `AgentRuntimeProvider` contract.
|
||||||
|
2. Write abuse-case tests before implementation for duplicate/unknown providers, immutable server-derived scope, capability denial, approval mismatch/absence, and audit failure.
|
||||||
|
3. Implement the Gateway service that converts only authenticated `ActorTenantScope` plus trusted ingress metadata into a frozen `RuntimeScope`, gates capabilities and terminate approval, and records metadata-only audits.
|
||||||
|
4. Register the service in `AgentModule`, then run focused, baseline, cold-cache, and independent-review gates.
|
||||||
|
|
||||||
|
## Security Invariants
|
||||||
|
|
||||||
|
- Caller-supplied actor/tenant identity never reaches runtime providers.
|
||||||
|
- Provider capability absence and approval/audit failure deny before side effects.
|
||||||
|
- Termination approval is verified against provider, session, actor, tenant, channel, and correlation context.
|
||||||
|
- Audit events retain correlation and authority metadata but never message content or approval material.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-12: Created fresh worktree from `origin/main` at `119f64e6`; source and Tess planning/security documentation reviewed.
|
||||||
|
- 2026-07-12: Security TDD added registry and gateway abuse tests before implementation.
|
||||||
|
- 2026-07-12: Implemented `AgentRuntimeProviderRegistry` and gateway `RuntimeProviderService`; registered both in `AgentModule` and documented the internal boundary.
|
||||||
|
- 2026-07-12: Independent review found two audit correctness issues. Remediated completion-audit failure handling and provider execution failures: pre-invocation denials are audited as `denied`; post-invocation errors as `failed`; completion audit failure does not misreport a completed effect as retryable.
|
||||||
|
- 2026-07-12: Final independent security review: no findings. Final code review had one false positive: `@mosaicstack/types` is already declared in `packages/agent/package.json`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
- TDD red: `pnpm --filter @mosaicstack/gateway test -- runtime-provider-registry.service.test.ts` failed before both audit remediations, as expected.
|
||||||
|
- Focused: package registry 2 tests and gateway security boundary 7 tests pass.
|
||||||
|
- Cold-cache: removed this worktree's `node_modules`, then `pnpm install --offline --frozen-lockfile --store-dir /home/jarvis/.local/share/pnpm/store` passed.
|
||||||
|
- Cold-cache baseline: `TURBO_FORCE=true pnpm typecheck` — 42/42 tasks passed; `TURBO_FORCE=true pnpm lint` — 23/23 tasks passed; `TURBO_FORCE=true pnpm format:check` passed; `TURBO_FORCE=true pnpm test` — 42/42 tasks passed (gateway 548 tests passed, 11 intentionally skipped).
|
||||||
|
|
||||||
|
## Risks / Blockers
|
||||||
|
|
||||||
|
- The canonical durable approval implementation is currently command-specific. This card introduces a fail-closed runtime approval verifier boundary so a runtime provider cannot terminate until its exact-action verifier is wired; later provider implementations cannot bypass it.
|
||||||
@@ -37,6 +37,12 @@ Required operations:
|
|||||||
|
|
||||||
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
|
Every call receives an immutable, server-derived actor/tenant/channel scope and correlation ID. Caller-supplied actor IDs are forbidden. Unsupported capabilities fail closed with typed errors.
|
||||||
|
|
||||||
|
### M1 Registry Boundary
|
||||||
|
|
||||||
|
`@mosaicstack/agent` owns the explicit `AgentRuntimeProviderRegistry`; duplicate provider IDs are rejected rather than replaced. Gateway owns `RuntimeProviderService`, which creates a frozen `RuntimeScope` from authenticated `ActorTenantScope` and trusted ingress channel/correlation metadata before every provider call. The service checks the declared provider capability before invoking a side effect and records metadata-only audit events (`providerId`, operation, outcome, actor/tenant/channel, correlation, and resource ID). It never records message bodies, idempotency keys, or approval references.
|
||||||
|
|
||||||
|
Termination is fail-closed: a runtime approval verifier must consume a one-time, exact action binding for the provider, session, actor, tenant, channel, and correlation ID before `terminate` reaches a provider. Until the durable verifier is wired, the default verifier denies termination. This internal service introduces no HTTP endpoint; later Discord, CLI, MCP, and provider adapters consume the same gateway boundary.
|
||||||
|
|
||||||
## Authority Model
|
## Authority Model
|
||||||
|
|
||||||
| Intent | Owner | Tess behavior |
|
| Intent | Owner | Tess behavior |
|
||||||
|
|||||||
@@ -1 +1,3 @@
|
|||||||
export const VERSION = '0.0.0';
|
export const VERSION = '0.0.0';
|
||||||
|
|
||||||
|
export * from './runtime-provider-registry.js';
|
||||||
|
|||||||
95
packages/agent/src/runtime-provider-registry.test.ts
Normal file
95
packages/agent/src/runtime-provider-registry.test.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type {
|
||||||
|
AgentRuntimeProvider,
|
||||||
|
RuntimeAttachHandle,
|
||||||
|
RuntimeAttachMode,
|
||||||
|
RuntimeCapabilitySet,
|
||||||
|
RuntimeHealth,
|
||||||
|
RuntimeMessage,
|
||||||
|
RuntimeScope,
|
||||||
|
RuntimeSession,
|
||||||
|
RuntimeSessionTree,
|
||||||
|
RuntimeStreamEvent,
|
||||||
|
} from '@mosaicstack/types';
|
||||||
|
import { AgentRuntimeProviderRegistry } from './runtime-provider-registry.js';
|
||||||
|
|
||||||
|
class TestRuntimeProvider implements AgentRuntimeProvider {
|
||||||
|
readonly id = 'test-runtime';
|
||||||
|
|
||||||
|
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||||
|
return { supported: ['session.list'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
async health(_scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||||
|
return { status: 'healthy', checkedAt: '2026-07-12T00:00:00.000Z' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSessions(_scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSessionTree(_scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async *streamSession(
|
||||||
|
_sessionId: string,
|
||||||
|
_cursor: string | undefined,
|
||||||
|
_scope: RuntimeScope,
|
||||||
|
): AsyncIterable<RuntimeStreamEvent> {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(
|
||||||
|
_sessionId: string,
|
||||||
|
_message: RuntimeMessage,
|
||||||
|
_scope: RuntimeScope,
|
||||||
|
): Promise<void> {}
|
||||||
|
|
||||||
|
async attach(
|
||||||
|
_sessionId: string,
|
||||||
|
_mode: RuntimeAttachMode,
|
||||||
|
_scope: RuntimeScope,
|
||||||
|
): Promise<RuntimeAttachHandle> {
|
||||||
|
return {
|
||||||
|
attachmentId: 'attachment-1',
|
||||||
|
sessionId: 'session-1',
|
||||||
|
mode: 'read',
|
||||||
|
expiresAt: '2026-07-12T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detach(_attachmentId: string, _scope: RuntimeScope): Promise<void> {}
|
||||||
|
|
||||||
|
async terminate(_sessionId: string, _approvalRef: string, _scope: RuntimeScope): Promise<void> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentRuntimeProviderRegistry', (): void => {
|
||||||
|
it('resolves only registered runtime providers', (): void => {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
const provider = new TestRuntimeProvider();
|
||||||
|
|
||||||
|
registry.register(provider);
|
||||||
|
|
||||||
|
expect(registry.get(provider.id)).toBe(provider);
|
||||||
|
expect(registry.get('unknown-runtime')).toBeUndefined();
|
||||||
|
expect(registry.list()).toEqual([provider]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate and blank provider identities rather than silently replacing a runtime', (): void => {
|
||||||
|
const registry = new AgentRuntimeProviderRegistry();
|
||||||
|
const provider = new TestRuntimeProvider();
|
||||||
|
|
||||||
|
registry.register(provider);
|
||||||
|
|
||||||
|
expect((): void => {
|
||||||
|
registry.register(provider);
|
||||||
|
}).toThrow(/already registered/);
|
||||||
|
expect((): void => {
|
||||||
|
registry.require('');
|
||||||
|
}).toThrow(/provider ID is required/);
|
||||||
|
expect((): void => {
|
||||||
|
registry.require('unknown-runtime');
|
||||||
|
}).toThrow(/not registered/);
|
||||||
|
});
|
||||||
|
});
|
||||||
40
packages/agent/src/runtime-provider-registry.ts
Normal file
40
packages/agent/src/runtime-provider-registry.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { AgentRuntimeProvider } from '@mosaicstack/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry for runtime providers. Registration is explicit and replacement is
|
||||||
|
* forbidden so a provider identity cannot be silently hijacked at runtime.
|
||||||
|
*/
|
||||||
|
export class AgentRuntimeProviderRegistry {
|
||||||
|
private readonly providers = new Map<string, AgentRuntimeProvider>();
|
||||||
|
|
||||||
|
register(provider: AgentRuntimeProvider): void {
|
||||||
|
const providerId = provider.id.trim();
|
||||||
|
if (providerId.length === 0) {
|
||||||
|
throw new Error('Runtime provider ID is required');
|
||||||
|
}
|
||||||
|
if (this.providers.has(providerId)) {
|
||||||
|
throw new Error(`Runtime provider is already registered: ${providerId}`);
|
||||||
|
}
|
||||||
|
this.providers.set(providerId, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(providerId: string): AgentRuntimeProvider | undefined {
|
||||||
|
return this.providers.get(providerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
require(providerId: string): AgentRuntimeProvider {
|
||||||
|
const normalizedProviderId = providerId.trim();
|
||||||
|
if (normalizedProviderId.length === 0) {
|
||||||
|
throw new Error('Runtime provider ID is required');
|
||||||
|
}
|
||||||
|
const provider = this.providers.get(normalizedProviderId);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Runtime provider is not registered: ${normalizedProviderId}`);
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
list(): AgentRuntimeProvider[] {
|
||||||
|
return Array.from(this.providers.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -75,6 +75,9 @@ importers:
|
|||||||
'@modelcontextprotocol/sdk':
|
'@modelcontextprotocol/sdk':
|
||||||
specifier: ^1.27.1
|
specifier: ^1.27.1
|
||||||
version: 1.27.1(zod@4.3.6)
|
version: 1.27.1(zod@4.3.6)
|
||||||
|
'@mosaicstack/agent':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/agent
|
||||||
'@mosaicstack/auth':
|
'@mosaicstack/auth':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/auth
|
version: link:../../packages/auth
|
||||||
|
|||||||
Reference in New Issue
Block a user