feat: add Tess runtime provider registry (#722)
This commit was merged in pull request #722.
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
"@mariozechner/pi-ai": "^0.65.0",
|
||||
"@mariozechner/pi-coding-agent": "^0.65.0",
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"@mosaicstack/agent": "workspace:^",
|
||||
"@mosaicstack/auth": "workspace:^",
|
||||
"@mosaicstack/brain": "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 { AgentRuntimeProviderRegistry } from '@mosaicstack/agent';
|
||||
import { AgentService } from './agent.service.js';
|
||||
import { ProviderService } from './provider.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 { SkillsModule } from '../skills/skills.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()
|
||||
@Module({
|
||||
@@ -23,6 +32,21 @@ import { GCModule } from '../gc/gc.module.js';
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
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,
|
||||
],
|
||||
controllers: [ProvidersController, SessionsController, AgentConfigsController, RoutingController],
|
||||
@@ -33,6 +57,8 @@ import { GCModule } from '../gc/gc.module.js';
|
||||
RoutingService,
|
||||
RoutingEngineService,
|
||||
SkillLoaderService,
|
||||
RuntimeProviderService,
|
||||
AGENT_RUNTIME_PROVIDER_REGISTRY,
|
||||
],
|
||||
})
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
| Intent | Owner | Tess behavior |
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
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':
|
||||
specifier: ^1.27.1
|
||||
version: 1.27.1(zod@4.3.6)
|
||||
'@mosaicstack/agent':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/agent
|
||||
'@mosaicstack/auth':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/auth
|
||||
|
||||
Reference in New Issue
Block a user