371 lines
12 KiB
TypeScript
371 lines
12 KiB
TypeScript
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 {
|
|
RuntimeProviderAuditService,
|
|
RuntimeProviderService,
|
|
type RuntimeAuditEvent,
|
|
type RuntimeAuditSink,
|
|
type RuntimeApprovalVerifier,
|
|
} from '../runtime-provider-registry.service.js';
|
|
|
|
process.env['MOSAIC_AGENT_NAME'] ??= 'test-runtime-agent';
|
|
|
|
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;
|
|
throwAuthorization = 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.throwAuthorization) {
|
|
throw Object.assign(new Error('provider authorization denied'), { code: 'forbidden' });
|
|
}
|
|
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(
|
|
expect.objectContaining({
|
|
providerId: 'fleet',
|
|
operation: 'session.send',
|
|
outcome: 'succeeded',
|
|
actorId: OWNER_SCOPE.userId,
|
|
tenantId: OWNER_SCOPE.tenantId,
|
|
channelId: CONTEXT.channelId,
|
|
correlationId: CONTEXT.correlationId,
|
|
resourceId: 'session-1',
|
|
durationMs: expect.any(Number),
|
|
}),
|
|
);
|
|
expect(JSON.stringify(audit.events)).not.toContain('hello');
|
|
expect(JSON.stringify(audit.events)).not.toContain('key-1');
|
|
});
|
|
|
|
it('does not block a provider operation when an unsafe resource ID is redacted in durable audit', async (): Promise<void> => {
|
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
let persisted: unknown;
|
|
const durableAudit = new RuntimeProviderAuditService({
|
|
logs: {
|
|
ingest: async (entry: unknown): Promise<unknown> => {
|
|
persisted = entry;
|
|
return entry;
|
|
},
|
|
},
|
|
} as never);
|
|
const service = makeService(provider, durableAudit);
|
|
|
|
await service.sendMessage(
|
|
'fleet',
|
|
'session/credential-canary=secret-value',
|
|
{ content: 'safe message', idempotencyKey: 'key-1' },
|
|
CONTEXT,
|
|
);
|
|
|
|
expect(provider.sentMessages).toHaveLength(1);
|
|
expect(JSON.stringify(persisted)).not.toContain('secret-value');
|
|
});
|
|
|
|
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 audit = new RecordingAuditSink();
|
|
const service = makeService(provider, audit, approval);
|
|
|
|
await expect(
|
|
service.terminate('fleet', 'session-1', 'forged-approval', CONTEXT),
|
|
).rejects.toThrow(/approval denied/);
|
|
expect(provider.terminateCalls).toBe(0);
|
|
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
|
});
|
|
|
|
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,
|
|
agentName: process.env['MOSAIC_AGENT_NAME'],
|
|
});
|
|
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',
|
|
]);
|
|
expect(audit.events.at(-1)).toMatchObject({
|
|
errorCode: 'provider_error',
|
|
durationMs: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it('records a provider authorization rejection as denied rather than provider failure', async (): Promise<void> => {
|
|
const provider = new RecordingRuntimeProvider(['session.send']);
|
|
provider.throwAuthorization = 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 authorization denied/);
|
|
expect(audit.events.at(-1)).toMatchObject({ outcome: 'denied', errorCode: 'policy_denied' });
|
|
});
|
|
|
|
it('persists only metadata-only runtime audit fields', async (): Promise<void> => {
|
|
let persisted: unknown;
|
|
const ingest = async (entry: unknown): Promise<unknown> => {
|
|
persisted = entry;
|
|
return entry;
|
|
};
|
|
const service = new RuntimeProviderAuditService({ logs: { ingest } } as never);
|
|
|
|
await service.record({
|
|
providerId: 'fleet',
|
|
operation: 'session.send',
|
|
outcome: 'succeeded',
|
|
actorId: 'owner-1',
|
|
tenantId: 'tenant-1',
|
|
channelId: 'cli',
|
|
correlationId: 'correlation-1',
|
|
resourceId: 'session-1',
|
|
durationMs: 12,
|
|
});
|
|
|
|
expect(persisted).toMatchObject({
|
|
content: 'runtime.provider.audit',
|
|
metadata: expect.objectContaining({ correlationId: 'correlation-1', durationMs: 12 }),
|
|
});
|
|
expect(JSON.stringify(persisted)).not.toContain('approval');
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|