chore: consolidate new foundation and archive v1 (#1495)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@mosaicstack/agent",
|
||||
"version": "0.0.2",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "packages/agent"
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mosaicstack/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://git.mosaicstack.dev/api/packages/mosaicstack/npm/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
ConnectorExecutionContext,
|
||||
ConnectorExecutionGrant,
|
||||
ConnectorLease,
|
||||
ConnectorLeaseAuditEvent,
|
||||
ConnectorLeaseStore,
|
||||
FencedConnectorAdapter,
|
||||
LogicalAgentBinding,
|
||||
} from '@mosaicstack/types';
|
||||
import {
|
||||
ConnectorLeaseCoordinator,
|
||||
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
} from './connector-lease.js';
|
||||
import type { ConnectorLeaseError } from './connector-lease.js';
|
||||
|
||||
const identity = { tenantId: 'tenant-a', logicalAgentId: 'mos' } as const;
|
||||
const binding: LogicalAgentBinding = { identity, bindingId: 'operator-chat' };
|
||||
const activeLease: ConnectorLease = {
|
||||
...binding,
|
||||
leaseId: '00000000-0000-4000-8000-000000000001',
|
||||
connectorId: 'connector-a',
|
||||
scopes: ['runtime.send', 'tool.execute'],
|
||||
leaseEpoch: '3',
|
||||
acquiredAt: '2026-07-14T17:00:00.000Z',
|
||||
heartbeatAt: '2026-07-14T17:00:00.000Z',
|
||||
expiresAt: '2026-07-14T17:10:00.000Z',
|
||||
};
|
||||
|
||||
class FakeLeaseStore implements ConnectorLeaseStore {
|
||||
lease: ConnectorLease | null = activeLease;
|
||||
readonly audits: ConnectorLeaseAuditEvent[] = [];
|
||||
|
||||
async acquire(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async takeover(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async heartbeat(): Promise<ConnectorLease> {
|
||||
if (!this.lease) throw new Error('fixture has no lease');
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async release(): Promise<void> {}
|
||||
|
||||
async findCurrent(): Promise<ConnectorLease | null> {
|
||||
return this.lease;
|
||||
}
|
||||
|
||||
async recordAudit(event: ConnectorLeaseAuditEvent): Promise<void> {
|
||||
this.audits.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
describe('ConnectorLeaseCoordinator fencing', (): void => {
|
||||
it('validates a server-minted grant immediately before invoking an adapter side effect', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-1',
|
||||
});
|
||||
const execute = vi.fn(async (_input: string, context: ConnectorExecutionContext) => context);
|
||||
const adapter: FencedConnectorAdapter<string, ConnectorExecutionContext> = { execute };
|
||||
|
||||
const context = await coordinator.executeGrant(grant, 'runtime.send', 'hello', adapter);
|
||||
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(context).toMatchObject({
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
leaseEpoch: '3',
|
||||
scopes: ['runtime.send'],
|
||||
});
|
||||
});
|
||||
|
||||
it('caps grant expiry to the durable current lease instead of submitted metadata', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
store.lease = { ...activeLease, expiresAt: '2026-07-14T17:01:05.000Z' };
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: { ...activeLease, expiresAt: '2026-07-14T18:00:00.000Z' },
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-durable-expiry',
|
||||
});
|
||||
|
||||
expect(grant.expiresAt).toBe('2026-07-14T17:01:05.000Z');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['forged clone', (grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({ ...grant })],
|
||||
[
|
||||
'cross-tenant clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
identity: { ...grant.identity, tenantId: 'tenant-b' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-agent clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
identity: { ...grant.identity, logicalAgentId: 'other-agent' },
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-binding clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
bindingId: 'other-binding',
|
||||
}),
|
||||
],
|
||||
[
|
||||
'cross-connector clone',
|
||||
(grant: ConnectorExecutionGrant): ConnectorExecutionGrant => ({
|
||||
...grant,
|
||||
connectorId: 'connector-b',
|
||||
}),
|
||||
],
|
||||
])('denies and audits a %s before adapter invocation', async (_label, forge): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const grant = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-forged',
|
||||
});
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(forge(grant), 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
expect(store.audits.at(-1)).toMatchObject({
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
correlationId: 'correlation-forged',
|
||||
});
|
||||
});
|
||||
|
||||
it('denies malformed forged grants with sanitized audit metadata', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(
|
||||
// @ts-expect-error Deliberately exercise malformed runtime input at the trust boundary.
|
||||
{},
|
||||
'runtime.send',
|
||||
undefined,
|
||||
adapter,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'forged_grant' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
expect(store.audits).toContainEqual(
|
||||
expect.objectContaining({
|
||||
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
correlationId: 'untrusted',
|
||||
reason: 'forged_grant',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects lease and grant TTLs above server-side safety caps', async (): Promise<void> => {
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, {
|
||||
now: (): Date => new Date('2026-07-14T17:01:00.000Z'),
|
||||
});
|
||||
expect(
|
||||
() =>
|
||||
new ConnectorLeaseCoordinator(store, {
|
||||
maxLeaseTtlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||
}),
|
||||
).toThrow(/no greater than/);
|
||||
|
||||
await expect(
|
||||
coordinator.acquire({
|
||||
identity,
|
||||
bindingId: 'operator-chat',
|
||||
connectorId: 'connector-a',
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: MAX_CONNECTOR_LEASE_TTL_MS + 1,
|
||||
correlationId: 'correlation-ttl',
|
||||
}),
|
||||
).rejects.toThrow(/no greater than/);
|
||||
await expect(
|
||||
coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: MAX_CONNECTOR_GRANT_TTL_MS + 1,
|
||||
correlationId: 'correlation-ttl',
|
||||
}),
|
||||
).rejects.toThrow(/no greater than/);
|
||||
});
|
||||
|
||||
it('denies stale epoch, expired grant, and unauthorized scope before side effects', async (): Promise<void> => {
|
||||
let now = new Date('2026-07-14T17:01:00.000Z');
|
||||
const store = new FakeLeaseStore();
|
||||
const coordinator = new ConnectorLeaseCoordinator(store, { now: (): Date => now });
|
||||
const stale = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-stale',
|
||||
});
|
||||
store.lease = { ...activeLease, leaseEpoch: '4', connectorId: 'connector-b' };
|
||||
const adapter = { execute: vi.fn().mockResolvedValue(undefined) };
|
||||
|
||||
await expect(
|
||||
coordinator.executeGrant(stale, 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'stale_epoch' } satisfies Partial<ConnectorLeaseError>);
|
||||
|
||||
store.lease = activeLease;
|
||||
const expiring = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 1_000,
|
||||
correlationId: 'correlation-expired',
|
||||
});
|
||||
now = new Date('2026-07-14T17:01:02.000Z');
|
||||
await expect(
|
||||
coordinator.executeGrant(expiring, 'runtime.send', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'grant_expired' } satisfies Partial<ConnectorLeaseError>);
|
||||
|
||||
now = new Date('2026-07-14T17:01:00.000Z');
|
||||
const scoped = await coordinator.issueGrant({
|
||||
lease: activeLease,
|
||||
scopes: ['runtime.send'],
|
||||
ttlMs: 30_000,
|
||||
correlationId: 'correlation-scope',
|
||||
});
|
||||
await expect(
|
||||
coordinator.executeGrant(scoped, 'tool.execute', undefined, adapter),
|
||||
).rejects.toMatchObject({ code: 'scope_denied' } satisfies Partial<ConnectorLeaseError>);
|
||||
expect(adapter.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,381 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
normalizeConnectorId,
|
||||
normalizeConnectorScope,
|
||||
normalizeConnectorScopes,
|
||||
normalizeCorrelationId,
|
||||
normalizeLeaseEpoch,
|
||||
normalizeLogicalAgentIdentity,
|
||||
normalizeLogicalBindingId,
|
||||
type AcquireConnectorLeaseInput,
|
||||
type ConnectorExecutionContext,
|
||||
type ConnectorExecutionGrant,
|
||||
type ConnectorLease,
|
||||
type ConnectorLeaseAuditEvent,
|
||||
type ConnectorLeaseRejectReason,
|
||||
type ConnectorLeaseStore,
|
||||
type FencedConnectorAdapter,
|
||||
type HeartbeatConnectorLeaseInput,
|
||||
type IssueConnectorExecutionGrantInput,
|
||||
type LogicalAgentBinding,
|
||||
type ReleaseConnectorLeaseInput,
|
||||
type TakeoverConnectorLeaseInput,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
export const MAX_CONNECTOR_LEASE_TTL_MS = 5 * 60 * 1000;
|
||||
export const MAX_CONNECTOR_GRANT_TTL_MS = 30 * 1000;
|
||||
|
||||
export interface ConnectorLeaseCoordinatorOptions {
|
||||
readonly now?: () => Date;
|
||||
readonly maxLeaseTtlMs?: number;
|
||||
readonly maxGrantTtlMs?: number;
|
||||
}
|
||||
|
||||
export class ConnectorLeaseError extends Error {
|
||||
constructor(
|
||||
readonly code: ConnectorLeaseRejectReason,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = ConnectorLeaseError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-neutral lease coordinator. Durable CAS lives in the store adapter;
|
||||
* grant provenance remains process-local so a restart fails closed and mints
|
||||
* fresh grants from the durable current lease.
|
||||
*/
|
||||
export class ConnectorLeaseCoordinator {
|
||||
private readonly issuedGrants = new WeakSet<object>();
|
||||
private readonly now: () => Date;
|
||||
private readonly maxLeaseTtlMs: number;
|
||||
private readonly maxGrantTtlMs: number;
|
||||
|
||||
constructor(
|
||||
private readonly store: ConnectorLeaseStore,
|
||||
options: ConnectorLeaseCoordinatorOptions = {},
|
||||
) {
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.maxLeaseTtlMs = normalizeTtlLimit(
|
||||
options.maxLeaseTtlMs ?? MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
MAX_CONNECTOR_LEASE_TTL_MS,
|
||||
'lease',
|
||||
);
|
||||
this.maxGrantTtlMs = normalizeTtlLimit(
|
||||
options.maxGrantTtlMs ?? MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
MAX_CONNECTOR_GRANT_TTL_MS,
|
||||
'grant',
|
||||
);
|
||||
}
|
||||
|
||||
async acquire(input: AcquireConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||
const now = this.now();
|
||||
return this.store.acquire({
|
||||
...command,
|
||||
leaseId: randomUUID(),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, command.ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async takeover(input: TakeoverConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const command = normalizeAcquireInput(input, this.maxLeaseTtlMs);
|
||||
const now = this.now();
|
||||
return this.store.takeover({
|
||||
...command,
|
||||
expectedEpoch: normalizeLeaseEpoch(input.expectedEpoch),
|
||||
leaseId: randomUUID(),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, command.ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async heartbeat(input: HeartbeatConnectorLeaseInput): Promise<ConnectorLease> {
|
||||
const now = this.now();
|
||||
const lease = normalizeConnectorLease(input.lease);
|
||||
const ttlMs = normalizeTtl(input.ttlMs, this.maxLeaseTtlMs, 'lease');
|
||||
return this.store.heartbeat({
|
||||
lease,
|
||||
ttlMs,
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
now: now.toISOString(),
|
||||
expiresAt: expiresAt(now, ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
async release(input: ReleaseConnectorLeaseInput): Promise<void> {
|
||||
await this.store.release({
|
||||
lease: normalizeConnectorLease(input.lease),
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
now: this.now().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async current(binding: LogicalAgentBinding): Promise<ConnectorLease | null> {
|
||||
return this.store.findCurrent(normalizeBinding(binding));
|
||||
}
|
||||
|
||||
async issueGrant(input: IssueConnectorExecutionGrantInput): Promise<ConnectorExecutionGrant> {
|
||||
const now = this.now();
|
||||
const lease = normalizeConnectorLease(input.lease);
|
||||
const scopes = normalizeConnectorScopes(input.scopes);
|
||||
const correlationId = normalizeCorrelationId(input.correlationId);
|
||||
const ttlMs = normalizeTtl(input.ttlMs, this.maxGrantTtlMs, 'grant');
|
||||
const current = await this.store.findCurrent(lease);
|
||||
await this.assertCurrentLease(current, lease, now, correlationId);
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
if (!isScopeSubset(scopes, lease.scopes) || !isScopeSubset(scopes, current.scopes)) {
|
||||
await this.reject(lease, correlationId, now, 'scope_denied');
|
||||
}
|
||||
const requestedExpiry = new Date(now.getTime() + ttlMs);
|
||||
const leaseExpiry = new Date(current.expiresAt);
|
||||
const grantExpiry = requestedExpiry < leaseExpiry ? requestedExpiry : leaseExpiry;
|
||||
const grant: ConnectorExecutionGrant = Object.freeze({
|
||||
identity: current.identity,
|
||||
bindingId: current.bindingId,
|
||||
leaseId: current.leaseId,
|
||||
connectorId: current.connectorId,
|
||||
scopes,
|
||||
leaseEpoch: current.leaseEpoch,
|
||||
issuedAt: now.toISOString(),
|
||||
expiresAt: grantExpiry.toISOString(),
|
||||
correlationId,
|
||||
});
|
||||
this.issuedGrants.add(grant);
|
||||
return grant;
|
||||
}
|
||||
|
||||
async executeGrant<TInput, TOutput>(
|
||||
grant: ConnectorExecutionGrant,
|
||||
requiredScope: string,
|
||||
input: TInput,
|
||||
adapter: FencedConnectorAdapter<TInput, TOutput>,
|
||||
): Promise<TOutput> {
|
||||
const now = this.now();
|
||||
const normalizedScope = normalizeConnectorScope(requiredScope);
|
||||
if (!this.issuedGrants.has(grant)) {
|
||||
await this.rejectForgedGrant(grant, now);
|
||||
}
|
||||
if (new Date(grant.expiresAt) <= now) {
|
||||
await this.rejectGrant(grant, now, 'grant_expired');
|
||||
}
|
||||
const current = await this.store.findCurrent(normalizeBinding(grant));
|
||||
await this.assertCurrentLease(current, grant, now, grant.correlationId);
|
||||
if (!grant.scopes.includes(normalizedScope) || !current?.scopes.includes(normalizedScope)) {
|
||||
await this.rejectGrant(grant, now, 'scope_denied');
|
||||
}
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
const context: ConnectorExecutionContext = Object.freeze({
|
||||
identity: current.identity,
|
||||
bindingId: current.bindingId,
|
||||
leaseId: current.leaseId,
|
||||
connectorId: current.connectorId,
|
||||
scopes: Object.freeze([...grant.scopes]),
|
||||
leaseEpoch: current.leaseEpoch,
|
||||
correlationId: grant.correlationId,
|
||||
grantExpiresAt: grant.expiresAt,
|
||||
});
|
||||
return adapter.execute(input, context);
|
||||
}
|
||||
|
||||
private async assertCurrentLease(
|
||||
current: ConnectorLease | null,
|
||||
authority: ConnectorLease | ConnectorExecutionGrant,
|
||||
now: Date,
|
||||
correlationId: string,
|
||||
): Promise<void> {
|
||||
if (!current) await this.reject(authority, correlationId, now, 'lease_missing');
|
||||
if (!current) throw new ConnectorLeaseError('lease_missing', 'Connector lease is unavailable');
|
||||
if (current.releasedAt) await this.reject(authority, correlationId, now, 'lease_released');
|
||||
if (new Date(current.expiresAt) <= now) {
|
||||
await this.store.recordAudit(auditEvent(current, correlationId, now, 'expiry', 'succeeded'));
|
||||
await this.reject(authority, correlationId, now, 'lease_expired');
|
||||
}
|
||||
if (current.leaseEpoch !== authority.leaseEpoch) {
|
||||
await this.reject(authority, correlationId, now, 'stale_epoch');
|
||||
}
|
||||
if (current.leaseId !== authority.leaseId || current.connectorId !== authority.connectorId) {
|
||||
await this.reject(authority, correlationId, now, 'connector_mismatch');
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectGrant(
|
||||
grant: ConnectorExecutionGrant,
|
||||
now: Date,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): Promise<never> {
|
||||
return this.reject(grant, grant.correlationId, now, reason);
|
||||
}
|
||||
|
||||
private async rejectForgedGrant(grant: unknown, now: Date): Promise<never> {
|
||||
const event = safeForgedGrantAudit(grant, now);
|
||||
await this.store.recordAudit(event);
|
||||
throw new ConnectorLeaseError('forged_grant', safeReasonMessage('forged_grant'));
|
||||
}
|
||||
|
||||
private async reject(
|
||||
authority: LogicalAgentBinding & {
|
||||
readonly connectorId: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
},
|
||||
correlationId: string,
|
||||
now: Date,
|
||||
reason: ConnectorLeaseRejectReason,
|
||||
): Promise<never> {
|
||||
await this.store.recordAudit(
|
||||
auditEvent(authority, correlationId, now, 'reject', 'denied', reason),
|
||||
);
|
||||
throw new ConnectorLeaseError(reason, safeReasonMessage(reason));
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAcquireInput(
|
||||
input: AcquireConnectorLeaseInput,
|
||||
maxLeaseTtlMs: number,
|
||||
): AcquireConnectorLeaseInput {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||
connectorId: normalizeConnectorId(input.connectorId),
|
||||
scopes: normalizeConnectorScopes(input.scopes),
|
||||
ttlMs: normalizeTtl(input.ttlMs, maxLeaseTtlMs, 'lease'),
|
||||
correlationId: normalizeCorrelationId(input.correlationId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBinding(input: LogicalAgentBinding): LogicalAgentBinding {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity(input.identity),
|
||||
bindingId: normalizeLogicalBindingId(input.bindingId),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeConnectorLease(lease: ConnectorLease): ConnectorLease {
|
||||
const binding = normalizeBinding(lease);
|
||||
return Object.freeze({
|
||||
...binding,
|
||||
leaseId: lease.leaseId,
|
||||
connectorId: normalizeConnectorId(lease.connectorId),
|
||||
scopes: normalizeConnectorScopes(lease.scopes),
|
||||
leaseEpoch: normalizeLeaseEpoch(lease.leaseEpoch),
|
||||
acquiredAt: normalizeTimestamp(lease.acquiredAt, 'lease acquisition'),
|
||||
heartbeatAt: normalizeTimestamp(lease.heartbeatAt, 'lease heartbeat'),
|
||||
expiresAt: normalizeTimestamp(lease.expiresAt, 'lease expiry'),
|
||||
...(lease.releasedAt
|
||||
? { releasedAt: normalizeTimestamp(lease.releasedAt, 'lease release') }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeTtl(ttlMs: number, maximum: number, kind: 'lease' | 'grant'): number {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > maximum) {
|
||||
throw new Error(
|
||||
`Connector ${kind} TTL must be a positive safe integer no greater than ${maximum}ms`,
|
||||
);
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
function normalizeTtlLimit(ttlMs: number, hardMaximum: number, kind: 'lease' | 'grant'): number {
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > hardMaximum) {
|
||||
throw new Error(
|
||||
`Maximum connector ${kind} TTL must be a positive safe integer no greater than ${hardMaximum}ms`,
|
||||
);
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
function normalizeTimestamp(value: string, label: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`${label} timestamp is invalid`);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function expiresAt(now: Date, ttlMs: number): string {
|
||||
const expiry = new Date(now.getTime() + ttlMs);
|
||||
if (Number.isNaN(expiry.getTime())) throw new Error('Connector lease TTL exceeds date range');
|
||||
return expiry.toISOString();
|
||||
}
|
||||
|
||||
function isScopeSubset(requested: readonly string[], allowed: readonly string[]): boolean {
|
||||
return requested.every((scope) => allowed.includes(scope));
|
||||
}
|
||||
|
||||
function auditEvent(
|
||||
authority: LogicalAgentBinding & {
|
||||
readonly connectorId: string;
|
||||
readonly leaseId?: string;
|
||||
readonly leaseEpoch?: string;
|
||||
},
|
||||
correlationId: string,
|
||||
now: Date,
|
||||
event: ConnectorLeaseAuditEvent['event'],
|
||||
outcome: ConnectorLeaseAuditEvent['outcome'],
|
||||
reason?: ConnectorLeaseRejectReason,
|
||||
): ConnectorLeaseAuditEvent {
|
||||
return {
|
||||
identity: authority.identity,
|
||||
bindingId: authority.bindingId,
|
||||
connectorId: authority.connectorId,
|
||||
correlationId,
|
||||
occurredAt: now.toISOString(),
|
||||
event,
|
||||
outcome,
|
||||
...(authority.leaseId ? { leaseId: authority.leaseId } : {}),
|
||||
...(authority.leaseEpoch ? { leaseEpoch: authority.leaseEpoch } : {}),
|
||||
...(reason ? { reason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function safeForgedGrantAudit(grant: unknown, now: Date): ConnectorLeaseAuditEvent {
|
||||
const fallback: ConnectorLeaseAuditEvent = {
|
||||
identity: { tenantId: 'untrusted', logicalAgentId: 'untrusted' },
|
||||
bindingId: 'untrusted',
|
||||
connectorId: 'untrusted',
|
||||
correlationId: 'untrusted',
|
||||
occurredAt: now.toISOString(),
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
};
|
||||
if (typeof grant !== 'object' || grant === null || !('identity' in grant)) return fallback;
|
||||
const identity = grant.identity;
|
||||
if (typeof identity !== 'object' || identity === null) return fallback;
|
||||
if (!('tenantId' in identity) || !('logicalAgentId' in identity)) return fallback;
|
||||
if (!('bindingId' in grant) || !('connectorId' in grant) || !('correlationId' in grant)) {
|
||||
return fallback;
|
||||
}
|
||||
if (
|
||||
typeof identity.tenantId !== 'string' ||
|
||||
typeof identity.logicalAgentId !== 'string' ||
|
||||
typeof grant.bindingId !== 'string' ||
|
||||
typeof grant.connectorId !== 'string' ||
|
||||
typeof grant.correlationId !== 'string'
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return {
|
||||
identity: normalizeLogicalAgentIdentity({
|
||||
tenantId: identity.tenantId,
|
||||
logicalAgentId: identity.logicalAgentId,
|
||||
}),
|
||||
bindingId: normalizeLogicalBindingId(grant.bindingId),
|
||||
connectorId: normalizeConnectorId(grant.connectorId),
|
||||
correlationId: normalizeCorrelationId(grant.correlationId),
|
||||
occurredAt: now.toISOString(),
|
||||
event: 'reject',
|
||||
outcome: 'denied',
|
||||
reason: 'forged_grant',
|
||||
};
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function safeReasonMessage(reason: ConnectorLeaseRejectReason): string {
|
||||
return `Connector authority denied: ${reason}`;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
DurableSessionCoordinator,
|
||||
InMemoryDurableSessionStore,
|
||||
type DurableSessionIdentity,
|
||||
} from './durable-session.js';
|
||||
|
||||
const IDENTITY: DurableSessionIdentity = {
|
||||
agentName: 'Nova',
|
||||
sessionId: 'tess-session-1',
|
||||
tenantId: 'tenant-1',
|
||||
ownerId: 'owner-1',
|
||||
providerId: 'fleet',
|
||||
runtimeSessionId: 'nova',
|
||||
};
|
||||
|
||||
describe('DurableSessionCoordinator', () => {
|
||||
it('reconstructs an exact session identity, pending inbox/outbox, checkpoint, and handoff after a simulated process restart', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const beforeRestart = new DurableSessionCoordinator(store);
|
||||
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-1',
|
||||
correlationId: 'correlation-1',
|
||||
content: 'continue the session',
|
||||
});
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-1',
|
||||
correlationId: 'correlation-1',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'resumable response',
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
cursor: 'cursor-42',
|
||||
summary: 'operator asked for recovery proof',
|
||||
compactionEpoch: 0,
|
||||
});
|
||||
await beforeRestart.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-1',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
// Simulate an ungraceful process death: no in-memory coordinator state survives.
|
||||
const afterRestart = new DurableSessionCoordinator(store);
|
||||
const recovered = await afterRestart.recover(IDENTITY.sessionId);
|
||||
|
||||
expect(recovered.identity).toEqual(IDENTITY);
|
||||
expect(recovered.inbox).toMatchObject([{ idempotencyKey: 'ingress-1', status: 'pending' }]);
|
||||
expect(recovered.outbox).toMatchObject([{ idempotencyKey: 'outbox-1', status: 'pending' }]);
|
||||
expect(recovered.checkpoint).toMatchObject({
|
||||
checkpointId: 'checkpoint-1',
|
||||
cursor: 'cursor-42',
|
||||
});
|
||||
expect(recovered.handoffs).toMatchObject([{ handoffId: 'handoff-1', status: 'pending' }]);
|
||||
});
|
||||
|
||||
it('rebinds a recovered runtime while preserving the immutable conversation owner scope', async () => {
|
||||
const coordinator = new DurableSessionCoordinator(new InMemoryDurableSessionStore());
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.create({
|
||||
...IDENTITY,
|
||||
providerId: 'fleet-next',
|
||||
runtimeSessionId: 'nova-next',
|
||||
});
|
||||
|
||||
await expect(coordinator.snapshot(IDENTITY.sessionId)).resolves.toMatchObject({
|
||||
identity: { ...IDENTITY, providerId: 'fleet-next', runtimeSessionId: 'nova-next' },
|
||||
});
|
||||
await expect(coordinator.create({ ...IDENTITY, ownerId: 'other-owner' })).rejects.toThrow(
|
||||
/identity conflict/,
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates duplicate ingress and never reprocesses an inbox record after restart or compaction', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const firstProcess = new DurableSessionCoordinator(store);
|
||||
const handled: string[] = [];
|
||||
|
||||
await firstProcess.create(IDENTITY);
|
||||
await expect(
|
||||
firstProcess.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-duplicate',
|
||||
correlationId: 'correlation-2',
|
||||
content: 'only process me once',
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: true });
|
||||
await expect(
|
||||
firstProcess.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-duplicate',
|
||||
correlationId: 'correlation-2',
|
||||
content: 'only process me once',
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: false, status: 'pending' });
|
||||
await expect(
|
||||
firstProcess.receive({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ingress-duplicate',
|
||||
correlationId: 'forged-correlation',
|
||||
content: 'only process me once',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
|
||||
await firstProcess.drainInbox(IDENTITY.sessionId, async (entry) => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
await firstProcess.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-after-inbox',
|
||||
cursor: 'cursor-43',
|
||||
summary: 'safe to compact',
|
||||
compactionEpoch: 1,
|
||||
});
|
||||
|
||||
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
|
||||
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
|
||||
await afterRestartAndCompaction.drainInbox(IDENTITY.sessionId, async (entry) => {
|
||||
handled.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(handled).toEqual(['ingress-duplicate']);
|
||||
});
|
||||
|
||||
it('does not redispatch an already applied outbox side effect after replay, restart, or compaction', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const beforeRestart = new DurableSessionCoordinator(store);
|
||||
const appliedEffects: string[] = [];
|
||||
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'effect-1',
|
||||
correlationId: 'correlation-3',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'send exactly once',
|
||||
});
|
||||
await expect(
|
||||
beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'effect-1',
|
||||
correlationId: 'correlation-3',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'send exactly once',
|
||||
}),
|
||||
).resolves.toMatchObject({ accepted: false, status: 'pending' });
|
||||
|
||||
await beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
|
||||
appliedEffects.push(entry.idempotencyKey);
|
||||
});
|
||||
await beforeRestart.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-after-effect',
|
||||
cursor: 'cursor-44',
|
||||
summary: 'effect persisted before compaction',
|
||||
compactionEpoch: 1,
|
||||
});
|
||||
|
||||
const afterRestartAndCompaction = new DurableSessionCoordinator(store);
|
||||
await afterRestartAndCompaction.recover(IDENTITY.sessionId);
|
||||
await afterRestartAndCompaction.dispatchOutbox(IDENTITY.sessionId, async (entry) => {
|
||||
appliedEffects.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(appliedEffects).toEqual(['effect-1']);
|
||||
});
|
||||
|
||||
it('rejects outbox idempotency-key reuse when immutable effect data differs', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const coordinator = new DurableSessionCoordinator(store);
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'original effect',
|
||||
});
|
||||
await expect(
|
||||
coordinator.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'outbox-conflict',
|
||||
correlationId: 'correlation-outbox',
|
||||
channelId: 'forged-channel',
|
||||
kind: 'provider.send',
|
||||
content: 'original effect',
|
||||
}),
|
||||
).rejects.toThrow(/idempotency conflict/);
|
||||
});
|
||||
|
||||
it('retains an ambiguous failed provider effect as processing until explicit recovery', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const beforeRestart = new DurableSessionCoordinator(store);
|
||||
|
||||
await beforeRestart.create(IDENTITY);
|
||||
await beforeRestart.enqueueOutbox({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
idempotencyKey: 'ambiguous-effect',
|
||||
correlationId: 'correlation-ambiguous',
|
||||
channelId: 'cli',
|
||||
kind: 'provider.send',
|
||||
content: 'preserve this effect claim',
|
||||
});
|
||||
await expect(
|
||||
beforeRestart.dispatchOutbox(IDENTITY.sessionId, async (): Promise<void> => {
|
||||
throw new Error('provider connection dropped after submit');
|
||||
}),
|
||||
).rejects.toThrow(/connection dropped/);
|
||||
|
||||
const afterRestart = new DurableSessionCoordinator(store);
|
||||
await afterRestart.recover(IDENTITY.sessionId);
|
||||
const calls: string[] = [];
|
||||
await afterRestart.dispatchOutbox(IDENTITY.sessionId, async (entry): Promise<void> => {
|
||||
calls.push(entry.idempotencyKey);
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(await afterRestart.snapshot(IDENTITY.sessionId)).toMatchObject({
|
||||
outbox: [{ idempotencyKey: 'ambiguous-effect', status: 'processing' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a handoff-id replay with different immutable state', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const coordinator = new DurableSessionCoordinator(store);
|
||||
|
||||
await coordinator.create(IDENTITY);
|
||||
await coordinator.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-conflict',
|
||||
cursor: 'cursor-conflict',
|
||||
summary: 'handoff conflict proof',
|
||||
compactionEpoch: 0,
|
||||
});
|
||||
await coordinator.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-conflict',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-conflict',
|
||||
checkpointId: 'checkpoint-conflict',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
await expect(
|
||||
coordinator.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-conflict',
|
||||
destination: 'forged-destination',
|
||||
correlationId: 'correlation-conflict',
|
||||
checkpointId: 'checkpoint-conflict',
|
||||
status: 'pending',
|
||||
}),
|
||||
).rejects.toThrow(/identity conflict/);
|
||||
});
|
||||
|
||||
it('keeps a handoff portable and resumes it from its durable checkpoint without process-local references', async () => {
|
||||
const store = new InMemoryDurableSessionStore();
|
||||
const source = new DurableSessionCoordinator(store);
|
||||
|
||||
await source.create(IDENTITY);
|
||||
await source.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-handoff',
|
||||
cursor: 'cursor-45',
|
||||
summary: 'portable state',
|
||||
compactionEpoch: 2,
|
||||
});
|
||||
await source.handoff({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
handoffId: 'handoff-portable',
|
||||
destination: 'mos',
|
||||
correlationId: 'correlation-4',
|
||||
checkpointId: 'checkpoint-handoff',
|
||||
status: 'pending',
|
||||
});
|
||||
await source.checkpoint({
|
||||
sessionId: IDENTITY.sessionId,
|
||||
checkpointId: 'checkpoint-later',
|
||||
cursor: 'cursor-46',
|
||||
summary: 'newer compacted state must not strand the handoff',
|
||||
compactionEpoch: 3,
|
||||
});
|
||||
|
||||
const receivingProcess = new DurableSessionCoordinator(store);
|
||||
const handoff = await receivingProcess.resumeHandoff('handoff-portable');
|
||||
|
||||
expect(handoff.identity).toEqual(IDENTITY);
|
||||
expect(handoff.checkpoint).toMatchObject({ checkpointId: 'checkpoint-handoff' });
|
||||
expect(handoff.handoff).toMatchObject({ handoffId: 'handoff-portable', destination: 'mos' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
export interface DurableSessionIdentity {
|
||||
/** Provisioned roster identity; isolates durable state between named agents. */
|
||||
agentName: string;
|
||||
sessionId: string;
|
||||
tenantId: string;
|
||||
ownerId: string;
|
||||
providerId: string;
|
||||
runtimeSessionId: string;
|
||||
}
|
||||
|
||||
export type DurableInboxStatus = 'pending' | 'processing' | 'processed';
|
||||
export type DurableOutboxStatus = 'pending' | 'processing' | 'delivered';
|
||||
|
||||
export interface DurableInboxInput {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DurableInboxEntry extends DurableInboxInput {
|
||||
status: DurableInboxStatus;
|
||||
}
|
||||
|
||||
export interface DurableOutboxInput {
|
||||
sessionId: string;
|
||||
idempotencyKey: string;
|
||||
correlationId: string;
|
||||
channelId: string;
|
||||
kind: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface DurableOutboxEntry extends DurableOutboxInput {
|
||||
status: DurableOutboxStatus;
|
||||
}
|
||||
|
||||
export interface DurableCheckpointInput {
|
||||
sessionId: string;
|
||||
checkpointId: string;
|
||||
cursor: string;
|
||||
summary: string;
|
||||
compactionEpoch: number;
|
||||
}
|
||||
|
||||
export interface DurableCheckpoint extends DurableCheckpointInput {}
|
||||
|
||||
export interface DurableHandoffInput {
|
||||
sessionId: string;
|
||||
handoffId: string;
|
||||
destination: string;
|
||||
correlationId: string;
|
||||
checkpointId: string;
|
||||
status: 'pending' | 'accepted';
|
||||
}
|
||||
|
||||
export interface DurableHandoff extends DurableHandoffInput {}
|
||||
|
||||
export interface DurableSessionSnapshot {
|
||||
identity: DurableSessionIdentity;
|
||||
inbox: DurableInboxEntry[];
|
||||
outbox: DurableOutboxEntry[];
|
||||
checkpoint?: DurableCheckpoint;
|
||||
handoffs: DurableHandoff[];
|
||||
}
|
||||
|
||||
export interface DurableHandoffRecovery {
|
||||
identity: DurableSessionIdentity;
|
||||
checkpoint: DurableCheckpoint;
|
||||
handoff: DurableHandoff;
|
||||
}
|
||||
|
||||
export interface DurableEnqueueResult<TStatus extends string> {
|
||||
accepted: boolean;
|
||||
status: TStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* A durable-state port. Implementations must atomically claim and complete work
|
||||
* records. Recovery may requeue interrupted inbox work, but never an
|
||||
* externally visible outbox effect: an ambiguous provider result remains
|
||||
* claimed until a separately authorized reconciliation proves it safe.
|
||||
*/
|
||||
export interface DurableSessionStore {
|
||||
create(identity: DurableSessionIdentity): Promise<void>;
|
||||
snapshot(sessionId: string): Promise<DurableSessionSnapshot | null>;
|
||||
enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>>;
|
||||
claimInbox(sessionId: string): Promise<DurableInboxEntry | null>;
|
||||
completeInbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
releaseInbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
enqueueOutbox(input: DurableOutboxInput): Promise<DurableEnqueueResult<DurableOutboxStatus>>;
|
||||
claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null>;
|
||||
claimOutboxByKey(sessionId: string, idempotencyKey: string): Promise<DurableOutboxEntry | null>;
|
||||
completeOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void>;
|
||||
checkpoint(input: DurableCheckpointInput): Promise<void>;
|
||||
findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null>;
|
||||
handoff(input: DurableHandoffInput): Promise<void>;
|
||||
findHandoff(handoffId: string): Promise<DurableHandoff | null>;
|
||||
requeueInFlight(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export class DurableSessionNotFoundError extends Error {
|
||||
constructor(sessionId: string) {
|
||||
super(`Durable session not found: ${sessionId}`);
|
||||
this.name = 'DurableSessionNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DurableSessionCoordinator {
|
||||
constructor(private readonly store: DurableSessionStore) {}
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
this.assertIdentity(identity);
|
||||
await this.store.create(identity);
|
||||
}
|
||||
|
||||
async receive(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||
this.assertRecord(input.sessionId, input.idempotencyKey, input.correlationId, input.content);
|
||||
return this.store.enqueueInbox(input);
|
||||
}
|
||||
|
||||
async enqueueOutbox(
|
||||
input: DurableOutboxInput,
|
||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||
this.assertRecord(
|
||||
input.sessionId,
|
||||
input.idempotencyKey,
|
||||
input.correlationId,
|
||||
input.channelId,
|
||||
input.content,
|
||||
);
|
||||
if (input.kind.trim().length === 0) throw new Error('Durable outbox kind is required');
|
||||
return this.store.enqueueOutbox(input);
|
||||
}
|
||||
|
||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||
this.assertRecord(input.sessionId, input.checkpointId, input.cursor, input.summary);
|
||||
if (!Number.isInteger(input.compactionEpoch) || input.compactionEpoch < 0) {
|
||||
throw new Error('Durable checkpoint compaction epoch must be a non-negative integer');
|
||||
}
|
||||
await this.store.checkpoint(input);
|
||||
}
|
||||
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
this.assertRecord(input.sessionId, input.handoffId, input.destination, input.correlationId);
|
||||
if (input.checkpointId.trim().length === 0)
|
||||
throw new Error('Durable handoff checkpoint is required');
|
||||
await this.store.handoff(input);
|
||||
}
|
||||
|
||||
/** Read durable state without changing claim status; safe during normal operation. */
|
||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot> {
|
||||
const snapshot = await this.store.snapshot(sessionId);
|
||||
if (!snapshot) throw new DurableSessionNotFoundError(sessionId);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Requeue interrupted inbox work during recovery; preserve ambiguous outbox claims. */
|
||||
async recover(sessionId: string): Promise<DurableSessionSnapshot> {
|
||||
await this.store.requeueInFlight(sessionId);
|
||||
return this.snapshot(sessionId);
|
||||
}
|
||||
|
||||
async drainInbox(
|
||||
sessionId: string,
|
||||
handler: (entry: DurableInboxEntry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const entry = await this.store.claimInbox(sessionId);
|
||||
if (!entry) return;
|
||||
try {
|
||||
await handler(entry);
|
||||
} catch (error: unknown) {
|
||||
await this.store.releaseInbox(sessionId, entry.idempotencyKey);
|
||||
throw error;
|
||||
}
|
||||
// If this write fails after the handler succeeded, leave the record
|
||||
// processing. A recovery path can retry it with its stable idempotency key.
|
||||
await this.store.completeInbox(sessionId, entry.idempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchOutbox(
|
||||
sessionId: string,
|
||||
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (;;) {
|
||||
const entry = await this.store.claimOutbox(sessionId);
|
||||
if (!entry) return;
|
||||
// A provider failure can be ambiguous: it may occur after the receiver
|
||||
// accepted the idempotency key. Preserve the claim for reconciliation.
|
||||
await dispatcher(entry);
|
||||
// Do not requeue an effect after it has been applied but before its
|
||||
// terminal state could be persisted; recovery preserves the claim.
|
||||
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchOutboxEntry(
|
||||
sessionId: string,
|
||||
idempotencyKey: string,
|
||||
dispatcher: (entry: DurableOutboxEntry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const entry = await this.store.claimOutboxByKey(sessionId, idempotencyKey);
|
||||
if (!entry) return;
|
||||
// A provider failure can be ambiguous, so this stays processing until
|
||||
// separately authorized reconciliation proves it safe to resolve.
|
||||
await dispatcher(entry);
|
||||
await this.store.completeOutbox(sessionId, entry.idempotencyKey);
|
||||
}
|
||||
|
||||
async resumeHandoff(handoffId: string): Promise<DurableHandoffRecovery> {
|
||||
const handoff = await this.store.findHandoff(handoffId);
|
||||
if (!handoff) throw new Error(`Durable handoff not found: ${handoffId}`);
|
||||
const snapshot = await this.snapshot(handoff.sessionId);
|
||||
const checkpoint = await this.store.findCheckpoint(handoff.sessionId, handoff.checkpointId);
|
||||
if (!checkpoint) {
|
||||
throw new Error(`Durable handoff checkpoint is unavailable: ${handoff.checkpointId}`);
|
||||
}
|
||||
return { identity: snapshot.identity, checkpoint, handoff };
|
||||
}
|
||||
|
||||
private assertIdentity(identity: DurableSessionIdentity): void {
|
||||
this.assertRecord(
|
||||
identity.agentName,
|
||||
identity.sessionId,
|
||||
identity.tenantId,
|
||||
identity.ownerId,
|
||||
identity.providerId,
|
||||
);
|
||||
if (identity.runtimeSessionId.trim().length === 0) {
|
||||
throw new Error('Durable runtime session identity is required');
|
||||
}
|
||||
}
|
||||
|
||||
private assertRecord(...values: string[]): void {
|
||||
if (values.some((value: string): boolean => value.trim().length === 0)) {
|
||||
throw new Error('Durable session records require non-empty fields');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface InMemorySessionState {
|
||||
identity: DurableSessionIdentity;
|
||||
inbox: Map<string, DurableInboxEntry>;
|
||||
outbox: Map<string, DurableOutboxEntry>;
|
||||
checkpoints: Map<string, DurableCheckpoint>;
|
||||
handoffs: Map<string, DurableHandoff>;
|
||||
}
|
||||
|
||||
/** Reference store for deterministic domain tests; production uses the gateway DB adapter. */
|
||||
export class InMemoryDurableSessionStore implements DurableSessionStore {
|
||||
private readonly sessions = new Map<string, InMemorySessionState>();
|
||||
|
||||
async create(identity: DurableSessionIdentity): Promise<void> {
|
||||
const existing = this.sessions.get(identity.sessionId);
|
||||
if (existing) {
|
||||
if (!sameEnrollmentScope(existing.identity, identity)) {
|
||||
throw new Error(`Durable session identity conflict: ${identity.sessionId}`);
|
||||
}
|
||||
existing.identity.providerId = identity.providerId;
|
||||
existing.identity.runtimeSessionId = identity.runtimeSessionId;
|
||||
return;
|
||||
}
|
||||
this.sessions.set(identity.sessionId, {
|
||||
identity: copyIdentity(identity),
|
||||
inbox: new Map(),
|
||||
outbox: new Map(),
|
||||
checkpoints: new Map(),
|
||||
handoffs: new Map(),
|
||||
});
|
||||
}
|
||||
|
||||
async snapshot(sessionId: string): Promise<DurableSessionSnapshot | null> {
|
||||
const state = this.sessions.get(sessionId);
|
||||
if (!state) return null;
|
||||
const checkpoint = latestCheckpoint(state.checkpoints);
|
||||
return {
|
||||
identity: copyIdentity(state.identity),
|
||||
inbox: [...state.inbox.values()].map(copyInbox),
|
||||
outbox: [...state.outbox.values()].map(copyOutbox),
|
||||
...(checkpoint ? { checkpoint: copyCheckpoint(checkpoint) } : {}),
|
||||
handoffs: [...state.handoffs.values()].map(copyHandoff),
|
||||
};
|
||||
}
|
||||
|
||||
async enqueueInbox(input: DurableInboxInput): Promise<DurableEnqueueResult<DurableInboxStatus>> {
|
||||
const state = this.require(input.sessionId);
|
||||
const existing = state.inbox.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (!sameInbox(existing, input)) {
|
||||
throw new Error(`Durable inbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: existing.status };
|
||||
}
|
||||
state.inbox.set(input.idempotencyKey, { ...input, status: 'pending' });
|
||||
return { accepted: true, status: 'pending' };
|
||||
}
|
||||
|
||||
async claimInbox(sessionId: string): Promise<DurableInboxEntry | null> {
|
||||
const state = this.require(sessionId);
|
||||
const entry = [...state.inbox.values()].find(
|
||||
(candidate: DurableInboxEntry): boolean => candidate.status === 'pending',
|
||||
);
|
||||
if (!entry) return null;
|
||||
entry.status = 'processing';
|
||||
return copyInbox(entry);
|
||||
}
|
||||
|
||||
async completeInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'processed';
|
||||
}
|
||||
|
||||
async releaseInbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).inbox, idempotencyKey, 'inbox').status = 'pending';
|
||||
}
|
||||
|
||||
async enqueueOutbox(
|
||||
input: DurableOutboxInput,
|
||||
): Promise<DurableEnqueueResult<DurableOutboxStatus>> {
|
||||
const state = this.require(input.sessionId);
|
||||
const existing = state.outbox.get(input.idempotencyKey);
|
||||
if (existing) {
|
||||
if (!sameOutbox(existing, input)) {
|
||||
throw new Error(`Durable outbox idempotency conflict: ${input.idempotencyKey}`);
|
||||
}
|
||||
return { accepted: false, status: existing.status };
|
||||
}
|
||||
state.outbox.set(input.idempotencyKey, { ...input, status: 'pending' });
|
||||
return { accepted: true, status: 'pending' };
|
||||
}
|
||||
|
||||
async claimOutbox(sessionId: string): Promise<DurableOutboxEntry | null> {
|
||||
const state = this.require(sessionId);
|
||||
const entry = [...state.outbox.values()].find(
|
||||
(candidate: DurableOutboxEntry): boolean => candidate.status === 'pending',
|
||||
);
|
||||
if (!entry) return null;
|
||||
entry.status = 'processing';
|
||||
return copyOutbox(entry);
|
||||
}
|
||||
|
||||
async claimOutboxByKey(
|
||||
sessionId: string,
|
||||
idempotencyKey: string,
|
||||
): Promise<DurableOutboxEntry | null> {
|
||||
const entry = this.require(sessionId).outbox.get(idempotencyKey);
|
||||
if (!entry || entry.status !== 'pending') return null;
|
||||
entry.status = 'processing';
|
||||
return copyOutbox(entry);
|
||||
}
|
||||
|
||||
async completeOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status =
|
||||
'delivered';
|
||||
}
|
||||
|
||||
async releaseOutbox(sessionId: string, idempotencyKey: string): Promise<void> {
|
||||
this.requireEntry(this.require(sessionId).outbox, idempotencyKey, 'outbox').status = 'pending';
|
||||
}
|
||||
|
||||
async checkpoint(input: DurableCheckpointInput): Promise<void> {
|
||||
const checkpoints = this.require(input.sessionId).checkpoints;
|
||||
const existing = checkpoints.get(input.checkpointId);
|
||||
if (existing && !sameCheckpoint(existing, input)) {
|
||||
throw new Error(`Durable checkpoint identity conflict: ${input.checkpointId}`);
|
||||
}
|
||||
if (!existing) checkpoints.set(input.checkpointId, { ...input });
|
||||
}
|
||||
|
||||
async findCheckpoint(sessionId: string, checkpointId: string): Promise<DurableCheckpoint | null> {
|
||||
const checkpoint = this.require(sessionId).checkpoints.get(checkpointId);
|
||||
return checkpoint ? copyCheckpoint(checkpoint) : null;
|
||||
}
|
||||
|
||||
async handoff(input: DurableHandoffInput): Promise<void> {
|
||||
const state = this.require(input.sessionId);
|
||||
if (!state.checkpoints.has(input.checkpointId)) {
|
||||
throw new Error(`Durable handoff checkpoint is unavailable: ${input.checkpointId}`);
|
||||
}
|
||||
const existing = state.handoffs.get(input.handoffId);
|
||||
if (existing && !sameHandoff(existing, input)) {
|
||||
throw new Error(`Durable handoff identity conflict: ${input.handoffId}`);
|
||||
}
|
||||
if (!existing) state.handoffs.set(input.handoffId, { ...input });
|
||||
}
|
||||
|
||||
async findHandoff(handoffId: string): Promise<DurableHandoff | null> {
|
||||
for (const state of this.sessions.values()) {
|
||||
const handoff = state.handoffs.get(handoffId);
|
||||
if (handoff) return copyHandoff(handoff);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async requeueInFlight(sessionId: string): Promise<void> {
|
||||
const state = this.require(sessionId);
|
||||
for (const entry of state.inbox.values()) {
|
||||
if (entry.status === 'processing') entry.status = 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
private require(sessionId: string): InMemorySessionState {
|
||||
const state = this.sessions.get(sessionId);
|
||||
if (!state) throw new DurableSessionNotFoundError(sessionId);
|
||||
return state;
|
||||
}
|
||||
|
||||
private requireEntry<T extends { status: string }>(
|
||||
records: Map<string, T>,
|
||||
idempotencyKey: string,
|
||||
kind: string,
|
||||
): T {
|
||||
const entry = records.get(idempotencyKey);
|
||||
if (!entry) throw new Error(`Durable ${kind} entry not found: ${idempotencyKey}`);
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
function sameEnrollmentScope(left: DurableSessionIdentity, right: DurableSessionIdentity): boolean {
|
||||
return (
|
||||
left.agentName === right.agentName &&
|
||||
left.sessionId === right.sessionId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.ownerId === right.ownerId
|
||||
);
|
||||
}
|
||||
|
||||
function sameInbox(left: DurableInboxEntry, right: DurableInboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameOutbox(left: DurableOutboxEntry, right: DurableOutboxInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.idempotencyKey === right.idempotencyKey &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.channelId === right.channelId &&
|
||||
left.kind === right.kind &&
|
||||
left.content === right.content
|
||||
);
|
||||
}
|
||||
|
||||
function sameCheckpoint(left: DurableCheckpoint, right: DurableCheckpointInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.cursor === right.cursor &&
|
||||
left.summary === right.summary &&
|
||||
left.compactionEpoch === right.compactionEpoch
|
||||
);
|
||||
}
|
||||
|
||||
function latestCheckpoint(
|
||||
checkpoints: Map<string, DurableCheckpoint>,
|
||||
): DurableCheckpoint | undefined {
|
||||
return [...checkpoints.values()].sort(
|
||||
(left: DurableCheckpoint, right: DurableCheckpoint): number =>
|
||||
right.compactionEpoch - left.compactionEpoch,
|
||||
)[0];
|
||||
}
|
||||
|
||||
function sameHandoff(left: DurableHandoff, right: DurableHandoffInput): boolean {
|
||||
return (
|
||||
left.sessionId === right.sessionId &&
|
||||
left.handoffId === right.handoffId &&
|
||||
left.destination === right.destination &&
|
||||
left.correlationId === right.correlationId &&
|
||||
left.checkpointId === right.checkpointId &&
|
||||
left.status === right.status
|
||||
);
|
||||
}
|
||||
|
||||
function copyIdentity(identity: DurableSessionIdentity): DurableSessionIdentity {
|
||||
return { ...identity };
|
||||
}
|
||||
|
||||
function copyInbox(entry: DurableInboxEntry): DurableInboxEntry {
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function copyOutbox(entry: DurableOutboxEntry): DurableOutboxEntry {
|
||||
return { ...entry };
|
||||
}
|
||||
|
||||
function copyCheckpoint(checkpoint: DurableCheckpoint): DurableCheckpoint {
|
||||
return { ...checkpoint };
|
||||
}
|
||||
|
||||
function copyHandoff(handoff: DurableHandoff): DurableHandoff {
|
||||
return { ...handoff };
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RuntimeScope } from '@mosaicstack/types';
|
||||
import { HermesRuntimeProvider, type HermesRuntimeTransport } from './hermes-runtime-provider.js';
|
||||
|
||||
const scope: RuntimeScope = { actorId: 'a', tenantId: 't', channelId: 'c', correlationId: 'r' };
|
||||
const transport = (capabilities = ['session.list', 'session.tree']): HermesRuntimeTransport => ({
|
||||
capabilities: vi.fn(async () => capabilities),
|
||||
health: vi.fn(async () => ({ status: 'healthy' })),
|
||||
sessions: vi.fn(async () => [
|
||||
{
|
||||
conversation_id: 'child',
|
||||
agent_id: 'hermes-a',
|
||||
parent_conversation_id: 'parent',
|
||||
status: 'running',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
conversation_id: 'parent',
|
||||
agent_id: 'hermes-a',
|
||||
status: 'unknown',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
]),
|
||||
stream: async function* () {},
|
||||
send: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
detach: vi.fn(),
|
||||
terminate: vi.fn(),
|
||||
});
|
||||
describe('HermesRuntimeProvider normalization boundary', () => {
|
||||
it('returns an exhaustive fail-closed transitional capability matrix', async () => {
|
||||
const provider = new HermesRuntimeProvider(transport());
|
||||
|
||||
await expect(provider.transitionalCapabilityMatrix(scope)).resolves.toEqual([
|
||||
{ capability: 'kanban', status: 'unsupported' },
|
||||
{ capability: 'skills', status: 'unsupported' },
|
||||
{ capability: 'memory', status: 'unsupported' },
|
||||
{ capability: 'tools', status: 'unsupported' },
|
||||
{ capability: 'cron', status: 'unsupported' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('denies unsupported transitional capabilities without calling Hermes', async () => {
|
||||
const hermes = transport();
|
||||
const provider = new HermesRuntimeProvider(hermes);
|
||||
|
||||
await expect(provider.assertTransitionalCapability('memory', scope)).rejects.toMatchObject({
|
||||
code: 'capability_unsupported',
|
||||
});
|
||||
expect(hermes.capabilities).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes legacy sessions without exposing legacy fields', async () => {
|
||||
const provider = new HermesRuntimeProvider(transport());
|
||||
await expect(provider.listSessions(scope)).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'child', runtimeId: 'hermes-a', state: 'active' }),
|
||||
]),
|
||||
);
|
||||
const result = await provider.listSessions(scope);
|
||||
expect(result[0]).not.toHaveProperty('conversation_id');
|
||||
});
|
||||
it('forms normalized hierarchy and fails closed for unbridged operations', async () => {
|
||||
const provider = new HermesRuntimeProvider(transport());
|
||||
await expect(provider.getSessionTree(scope)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
session: expect.objectContaining({ id: 'parent', state: 'failed' }),
|
||||
children: [expect.objectContaining({ session: expect.objectContaining({ id: 'child' }) })],
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
provider.sendMessage('parent', { content: 'x', idempotencyKey: 'i' }, scope),
|
||||
).rejects.toMatchObject({ code: 'capability_unsupported' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeAttachMode,
|
||||
RuntimeCapability,
|
||||
RuntimeCapabilitySet,
|
||||
RuntimeHealth,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeSession,
|
||||
RuntimeSessionState,
|
||||
RuntimeSessionTree,
|
||||
RuntimeStreamEvent,
|
||||
TransitionalCapabilityInventoryEntry,
|
||||
TransitionalCapabilityInventoryProvider,
|
||||
TransitionalRuntimeCapability,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
const HERMES_PROVIDER_ID = 'runtime.hermes';
|
||||
const TRANSITIONAL_CAPABILITIES: readonly TransitionalRuntimeCapability[] = [
|
||||
'kanban',
|
||||
'skills',
|
||||
'memory',
|
||||
'tools',
|
||||
'cron',
|
||||
];
|
||||
const RUNTIME_CAPABILITIES: readonly RuntimeCapability[] = [
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.stream',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
];
|
||||
|
||||
/** Legacy transport boundary. These shapes are intentionally adapter-local. */
|
||||
export interface HermesLegacySession {
|
||||
conversation_id: string;
|
||||
agent_id: string;
|
||||
parent_conversation_id?: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface HermesRuntimeTransport {
|
||||
capabilities(scope: RuntimeScope): Promise<string[]>;
|
||||
health(scope: RuntimeScope): Promise<{ status: string; detail?: string }>;
|
||||
sessions(scope: RuntimeScope): Promise<HermesLegacySession[]>;
|
||||
stream(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
||||
attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle>;
|
||||
detach(attachmentId: string, scope: RuntimeScope): Promise<void>;
|
||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
||||
}
|
||||
|
||||
export class HermesRuntimeProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: 'capability_unsupported' | 'invalid_request',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = HermesRuntimeProviderError.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transitional Hermes adapter. Legacy identifiers and schemas do not cross this
|
||||
* boundary: callers only observe Mosaic AgentRuntimeProvider contracts.
|
||||
*/
|
||||
export class HermesRuntimeProvider
|
||||
implements AgentRuntimeProvider, TransitionalCapabilityInventoryProvider
|
||||
{
|
||||
readonly id = HERMES_PROVIDER_ID;
|
||||
|
||||
constructor(private readonly transport: HermesRuntimeTransport) {}
|
||||
|
||||
/**
|
||||
* Full AC-TESS-05 migration inventory. These operations are deliberately
|
||||
* unsupported until their Mosaic-owned plugin contracts exist.
|
||||
*/
|
||||
async transitionalCapabilityMatrix(
|
||||
_scope: RuntimeScope,
|
||||
): Promise<TransitionalCapabilityInventoryEntry[]> {
|
||||
return TRANSITIONAL_CAPABILITIES.map((capability) => ({ capability, status: 'unsupported' }));
|
||||
}
|
||||
|
||||
async assertTransitionalCapability(
|
||||
capability: TransitionalRuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
const entry = (await this.transitionalCapabilityMatrix(scope)).find(
|
||||
(candidate) => candidate.capability === capability,
|
||||
);
|
||||
if (!entry || entry.status !== 'supported') {
|
||||
throw new HermesRuntimeProviderError(
|
||||
'capability_unsupported',
|
||||
`Hermes transitional capability is unsupported: ${capability}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async capabilities(scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||
const legacyCapabilities = await this.transport.capabilities(scope);
|
||||
return {
|
||||
supported: RUNTIME_CAPABILITIES.filter((capability) =>
|
||||
legacyCapabilities.includes(capability),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||
const health = await this.transport.health(scope);
|
||||
return {
|
||||
status: health.status === 'healthy' || health.status === 'degraded' ? health.status : 'down',
|
||||
checkedAt: new Date().toISOString(),
|
||||
...(health.detail ? { detail: health.detail } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||
await this.requireCapability('session.list', scope);
|
||||
return (await this.transport.sessions(scope)).map((session) => this.session(session));
|
||||
}
|
||||
|
||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||
await this.requireCapability('session.tree', scope);
|
||||
const sessions = (await this.transport.sessions(scope)).map((session) => this.session(session));
|
||||
const nodes = new Map<string, RuntimeSessionTree>(
|
||||
sessions.map((session) => [session.id, { session, children: [] }]),
|
||||
);
|
||||
const roots: RuntimeSessionTree[] = [];
|
||||
for (const session of sessions) {
|
||||
const node = nodes.get(session.id)!;
|
||||
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async *streamSession(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
await this.requireCapability('session.stream', scope);
|
||||
yield* this.transport.stream(sessionId, cursor, scope);
|
||||
}
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
message: RuntimeMessage,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
await this.requireCapability('session.send', scope);
|
||||
if (!message.content.trim())
|
||||
throw new HermesRuntimeProviderError('invalid_request', 'Message content is required');
|
||||
await this.transport.send(sessionId, message, scope);
|
||||
}
|
||||
async attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle> {
|
||||
await this.requireCapability('session.attach', scope);
|
||||
return this.transport.attach(sessionId, mode, scope);
|
||||
}
|
||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.transport.detach(attachmentId, scope);
|
||||
}
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
await this.requireCapability('session.terminate', scope);
|
||||
if (!approvalRef.trim())
|
||||
throw new HermesRuntimeProviderError('invalid_request', 'Termination approval is required');
|
||||
await this.transport.terminate(sessionId, approvalRef, scope);
|
||||
}
|
||||
|
||||
private async requireCapability(
|
||||
capability: RuntimeCapability,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
if (!(await this.capabilities(scope)).supported.includes(capability)) {
|
||||
throw new HermesRuntimeProviderError(
|
||||
'capability_unsupported',
|
||||
`Hermes does not bridge ${capability}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
private session(value: HermesLegacySession): RuntimeSession {
|
||||
return {
|
||||
id: value.conversation_id,
|
||||
providerId: this.id,
|
||||
runtimeId: value.agent_id,
|
||||
...(value.parent_conversation_id ? { parentSessionId: value.parent_conversation_id } : {}),
|
||||
state: state(value.status),
|
||||
createdAt: value.created_at,
|
||||
updatedAt: value.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
function state(value: string): RuntimeSessionState {
|
||||
return (
|
||||
(
|
||||
{ running: 'active', waiting: 'idle', starting: 'starting', stopped: 'stopped' } as Record<
|
||||
string,
|
||||
RuntimeSessionState
|
||||
>
|
||||
)[value] ?? 'failed'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export const VERSION = '0.0.0';
|
||||
|
||||
export * from './runtime-provider-registry.js';
|
||||
export * from './tmux-fleet-runtime-provider.js';
|
||||
export * from './hermes-runtime-provider.js';
|
||||
export * from './matrix-native-runtime-provider.js';
|
||||
export * from './durable-session.js';
|
||||
export * from './connector-lease.js';
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RuntimeScope, RuntimeStreamEvent } from '@mosaicstack/types';
|
||||
import {
|
||||
type MatrixRuntimeSession,
|
||||
type MatrixRuntimeTransport,
|
||||
MatrixNativeRuntimeProvider,
|
||||
type MatrixReadAuthority,
|
||||
type MatrixWriteAuthority,
|
||||
} from './matrix-native-runtime-provider.js';
|
||||
|
||||
const scope: RuntimeScope = {
|
||||
actorId: 'operator-1',
|
||||
tenantId: 'tenant-a',
|
||||
channelId: 'matrix-control',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const session: MatrixRuntimeSession = {
|
||||
id: 'native-1',
|
||||
runtimeId: '@worker:example.test',
|
||||
state: 'active',
|
||||
createdAt: '2026-07-13T00:00:00.000Z',
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
};
|
||||
|
||||
function transport(): MatrixRuntimeTransport {
|
||||
return {
|
||||
health: vi.fn(async () => ({
|
||||
status: 'healthy' as const,
|
||||
checkedAt: '2026-07-13T00:00:00.000Z',
|
||||
})),
|
||||
listSessions: vi.fn(async () => [session]),
|
||||
verifySession: vi.fn(async (sessionId) => {
|
||||
if (sessionId !== session.id) throw new Error('unexpected session');
|
||||
return session;
|
||||
}),
|
||||
stream: vi.fn(async function* (): AsyncIterable<RuntimeStreamEvent> {
|
||||
yield {
|
||||
type: 'message.delta',
|
||||
sessionId: 'native-1',
|
||||
cursor: 'cursor-1',
|
||||
occurredAt: '2026-07-13T00:00:00.000Z',
|
||||
content: 'hello',
|
||||
};
|
||||
}),
|
||||
send: vi.fn(async () => undefined),
|
||||
terminate: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function readAuthority(): MatrixReadAuthority {
|
||||
return { canRead: vi.fn(async () => true) };
|
||||
}
|
||||
|
||||
function writeAuthority(): MatrixWriteAuthority {
|
||||
return {
|
||||
canWrite: vi.fn(async () => true),
|
||||
assertAuthorized: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('MatrixNativeRuntimeProvider contract boundary', (): void => {
|
||||
it('advertises the concrete Matrix operations and returns only normalized sessions', async (): Promise<void> => {
|
||||
const provider = new MatrixNativeRuntimeProvider({
|
||||
transport: transport(),
|
||||
readAuthority: readAuthority(),
|
||||
});
|
||||
|
||||
await expect(provider.capabilities(scope)).resolves.toEqual({
|
||||
supported: [
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.stream',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
],
|
||||
});
|
||||
await expect(provider.listSessions(scope)).resolves.toEqual([
|
||||
expect.objectContaining({ id: 'native-1', providerId: 'runtime.matrix' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('binds read attachments to immutable scope and rejects control mode before transport access', async (): Promise<void> => {
|
||||
const matrix = transport();
|
||||
const provider = new MatrixNativeRuntimeProvider({
|
||||
transport: matrix,
|
||||
readAuthority: readAuthority(),
|
||||
attachmentIdFactory: () => 'attachment-1',
|
||||
now: () => new Date('2026-07-13T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(provider.attach('native-1', 'control', scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
});
|
||||
expect(matrix.verifySession).not.toHaveBeenCalled();
|
||||
|
||||
await provider.attach('native-1', 'read', scope);
|
||||
await expect(
|
||||
provider.detach('attachment-1', { ...scope, tenantId: 'other' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('validates messages and applies exact Matrix authority after bound-session verification', async (): Promise<void> => {
|
||||
const matrix = transport();
|
||||
const writes = writeAuthority();
|
||||
const provider = new MatrixNativeRuntimeProvider({
|
||||
transport: matrix,
|
||||
readAuthority: readAuthority(),
|
||||
writeAuthority: writes,
|
||||
});
|
||||
|
||||
await expect(
|
||||
provider.sendMessage('native-1', { content: '', idempotencyKey: 'msg-1' }, scope),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
});
|
||||
expect(matrix.verifySession).not.toHaveBeenCalled();
|
||||
|
||||
await provider.sendMessage('native-1', { content: 'hello', idempotencyKey: 'msg-1' }, scope);
|
||||
expect(writes.assertAuthorized).toHaveBeenCalledWith({
|
||||
operation: 'session.send',
|
||||
sessionId: 'native-1',
|
||||
scope,
|
||||
});
|
||||
expect(matrix.send).toHaveBeenCalledWith(
|
||||
'native-1',
|
||||
{ content: 'hello', idempotencyKey: 'msg-1' },
|
||||
scope,
|
||||
);
|
||||
});
|
||||
|
||||
it('streams only after exact read authorization', async (): Promise<void> => {
|
||||
const matrix = transport();
|
||||
const provider = new MatrixNativeRuntimeProvider({
|
||||
transport: matrix,
|
||||
readAuthority: readAuthority(),
|
||||
});
|
||||
|
||||
await expect(collect(provider.streamSession('native-1', 'cursor-0', scope))).resolves.toEqual([
|
||||
expect.objectContaining({ type: 'message.delta', sessionId: 'native-1' }),
|
||||
]);
|
||||
expect(matrix.stream).toHaveBeenCalledWith('native-1', 'cursor-0', scope);
|
||||
});
|
||||
});
|
||||
|
||||
async function collect(stream: AsyncIterable<RuntimeStreamEvent>): Promise<RuntimeStreamEvent[]> {
|
||||
const values: RuntimeStreamEvent[] = [];
|
||||
for await (const value of stream) values.push(value);
|
||||
return values;
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeAttachMode,
|
||||
RuntimeCapabilitySet,
|
||||
RuntimeHealth,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeSession,
|
||||
RuntimeSessionTree,
|
||||
RuntimeStreamEvent,
|
||||
} from '@mosaicstack/types';
|
||||
|
||||
const MATRIX_PROVIDER_ID = 'runtime.matrix';
|
||||
const ATTACHMENT_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
/** A verified native runtime session. Matrix room and event details remain transport-local. */
|
||||
export interface MatrixRuntimeSession {
|
||||
id: string;
|
||||
runtimeId: string;
|
||||
parentSessionId?: string;
|
||||
state: RuntimeSession['state'];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow native Matrix boundary. The concrete Mosaic transport owns homeserver
|
||||
* authentication, exact room mapping, Matrix identity checks, and replay cursors.
|
||||
*/
|
||||
export interface MatrixRuntimeTransport {
|
||||
health(scope: RuntimeScope): Promise<RuntimeHealth>;
|
||||
listSessions(scope: RuntimeScope): Promise<MatrixRuntimeSession[]>;
|
||||
verifySession(sessionId: string, scope: RuntimeScope): Promise<MatrixRuntimeSession>;
|
||||
stream(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent>;
|
||||
send(sessionId: string, message: RuntimeMessage, scope: RuntimeScope): Promise<void>;
|
||||
terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void>;
|
||||
}
|
||||
|
||||
export type MatrixRuntimeProviderErrorCode =
|
||||
| 'capability_unsupported'
|
||||
| 'forbidden'
|
||||
| 'invalid_request'
|
||||
| 'not_found';
|
||||
|
||||
export class MatrixRuntimeProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: MatrixRuntimeProviderErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = MatrixRuntimeProviderError.name;
|
||||
}
|
||||
}
|
||||
|
||||
export type MatrixReadOperation =
|
||||
| 'runtime.health'
|
||||
| 'session.list'
|
||||
| 'session.tree'
|
||||
| 'session.stream'
|
||||
| 'session.attach';
|
||||
|
||||
export interface MatrixReadAuthority {
|
||||
canRead(input: {
|
||||
operation: MatrixReadOperation;
|
||||
scope: RuntimeScope;
|
||||
sessionId?: string;
|
||||
}): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface MatrixWriteAuthority {
|
||||
canWrite(input: {
|
||||
operation: 'session.send' | 'session.terminate';
|
||||
sessionId: string;
|
||||
scope: RuntimeScope;
|
||||
approvalRef?: string;
|
||||
}): Promise<boolean>;
|
||||
assertAuthorized(input: {
|
||||
operation: 'session.send' | 'session.terminate';
|
||||
sessionId: string;
|
||||
scope: RuntimeScope;
|
||||
approvalRef?: string;
|
||||
}): Promise<void>;
|
||||
}
|
||||
|
||||
export interface MatrixNativeRuntimeProviderOptions {
|
||||
transport: MatrixRuntimeTransport;
|
||||
readAuthority?: MatrixReadAuthority;
|
||||
writeAuthority?: MatrixWriteAuthority;
|
||||
attachmentIdFactory?: () => string;
|
||||
now?: () => Date;
|
||||
attachmentTtlMs?: number;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
sessionId: string;
|
||||
scope: RuntimeScope;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
class DenyMatrixReadAuthority implements MatrixReadAuthority {
|
||||
async canRead(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class DenyMatrixWriteAuthority implements MatrixWriteAuthority {
|
||||
async canWrite(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async assertAuthorized(): Promise<void> {
|
||||
throw new MatrixRuntimeProviderError(
|
||||
'forbidden',
|
||||
'Matrix runtime writes require orchestrator authority',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Native Matrix adapter behind the Mosaic runtime contract. It accepts only
|
||||
* stable session IDs; room identifiers, Matrix event schemas, and credentials
|
||||
* are deliberately confined to the concrete transport implementation.
|
||||
*/
|
||||
export class MatrixNativeRuntimeProvider implements AgentRuntimeProvider {
|
||||
readonly id = MATRIX_PROVIDER_ID;
|
||||
private readonly readAuthority: MatrixReadAuthority;
|
||||
private readonly writeAuthority: MatrixWriteAuthority;
|
||||
private readonly attachmentIdFactory: () => string;
|
||||
private readonly now: () => Date;
|
||||
private readonly attachmentTtlMs: number;
|
||||
private readonly attachments = new Map<string, Attachment>();
|
||||
|
||||
constructor(private readonly options: MatrixNativeRuntimeProviderOptions) {
|
||||
this.readAuthority = options.readAuthority ?? new DenyMatrixReadAuthority();
|
||||
this.writeAuthority = options.writeAuthority ?? new DenyMatrixWriteAuthority();
|
||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
||||
}
|
||||
|
||||
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||
return {
|
||||
supported: [
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.stream',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||
await this.assertRead('runtime.health', undefined, scope);
|
||||
return this.options.transport.health(scope);
|
||||
}
|
||||
|
||||
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||
await this.assertRead('session.list', undefined, scope);
|
||||
const sessions = await this.options.transport.listSessions(scope);
|
||||
const visible = await Promise.all(
|
||||
sessions.map((session) =>
|
||||
this.readAuthority.canRead({ operation: 'session.list', sessionId: session.id, scope }),
|
||||
),
|
||||
);
|
||||
return sessions
|
||||
.filter((_session, index) => visible[index] === true)
|
||||
.map((session) => this.runtimeSession(session));
|
||||
}
|
||||
|
||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||
await this.assertRead('session.tree', undefined, scope);
|
||||
const sessions = await this.options.transport.listSessions(scope);
|
||||
const visible = await Promise.all(
|
||||
sessions.map((session) =>
|
||||
this.readAuthority.canRead({ operation: 'session.tree', sessionId: session.id, scope }),
|
||||
),
|
||||
);
|
||||
const runtimeSessions = sessions
|
||||
.filter((_session, index) => visible[index] === true)
|
||||
.map((session) => this.runtimeSession(session));
|
||||
const nodes = new Map<string, RuntimeSessionTree>(
|
||||
runtimeSessions.map((session) => [session.id, { session, children: [] }]),
|
||||
);
|
||||
const roots: RuntimeSessionTree[] = [];
|
||||
for (const session of runtimeSessions) {
|
||||
const node = nodes.get(session.id)!;
|
||||
const parent = session.parentSessionId ? nodes.get(session.parentSessionId) : undefined;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
async *streamSession(
|
||||
sessionId: string,
|
||||
cursor: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
await this.assertRead('session.stream', sessionId, scope);
|
||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
||||
await this.assertRead('session.stream', session.id, scope);
|
||||
yield* this.options.transport.stream(session.id, cursor, scope);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
message: RuntimeMessage,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
if (!message.content.trim()) {
|
||||
throw new MatrixRuntimeProviderError(
|
||||
'invalid_request',
|
||||
'Matrix runtime message content is required',
|
||||
);
|
||||
}
|
||||
await this.assertWritePermitted('session.send', sessionId, scope);
|
||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
||||
await this.assertWriteAuthorized('session.send', session.id, scope);
|
||||
await this.options.transport.send(session.id, message, scope);
|
||||
}
|
||||
|
||||
async attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle> {
|
||||
if (mode !== 'read') {
|
||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix control attach is not permitted');
|
||||
}
|
||||
await this.assertRead('session.attach', sessionId, scope);
|
||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
||||
await this.assertRead('session.attach', session.id, scope);
|
||||
const nowMs = this.now().getTime();
|
||||
this.pruneExpired(nowMs);
|
||||
const attachmentId = this.attachmentIdFactory();
|
||||
const expiresAtMs = nowMs + this.attachmentTtlMs;
|
||||
this.attachments.set(attachmentId, {
|
||||
sessionId: session.id,
|
||||
scope: snapshotScope(scope),
|
||||
expiresAtMs,
|
||||
});
|
||||
return {
|
||||
attachmentId,
|
||||
sessionId: session.id,
|
||||
mode,
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||
const attachment = this.attachments.get(attachmentId);
|
||||
if (!attachment)
|
||||
throw new MatrixRuntimeProviderError('not_found', 'Matrix attachment is not active');
|
||||
if (this.now().getTime() >= attachment.expiresAtMs) {
|
||||
this.attachments.delete(attachmentId);
|
||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment has expired');
|
||||
}
|
||||
if (!sameScope(attachment.scope, scope)) {
|
||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix attachment scope does not match');
|
||||
}
|
||||
this.attachments.delete(attachmentId);
|
||||
}
|
||||
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
if (!approvalRef.trim()) {
|
||||
throw new MatrixRuntimeProviderError(
|
||||
'invalid_request',
|
||||
'Matrix termination approval is required',
|
||||
);
|
||||
}
|
||||
await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef);
|
||||
const session = await this.options.transport.verifySession(sessionId, scope);
|
||||
await this.assertWriteAuthorized('session.terminate', session.id, scope, approvalRef);
|
||||
await this.options.transport.terminate(session.id, approvalRef, scope);
|
||||
}
|
||||
|
||||
private runtimeSession(session: MatrixRuntimeSession): RuntimeSession {
|
||||
return { ...session, providerId: this.id };
|
||||
}
|
||||
|
||||
private async assertRead(
|
||||
operation: MatrixReadOperation,
|
||||
sessionId: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
const allowed = await this.readAuthority.canRead({
|
||||
operation,
|
||||
scope,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
});
|
||||
if (!allowed)
|
||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime read is not authorized');
|
||||
}
|
||||
|
||||
private async assertWritePermitted(
|
||||
operation: 'session.send' | 'session.terminate',
|
||||
sessionId: string,
|
||||
scope: RuntimeScope,
|
||||
approvalRef?: string,
|
||||
): Promise<void> {
|
||||
const allowed = await this.writeAuthority.canWrite({
|
||||
operation,
|
||||
sessionId,
|
||||
scope,
|
||||
...(approvalRef ? { approvalRef } : {}),
|
||||
});
|
||||
if (!allowed)
|
||||
throw new MatrixRuntimeProviderError('forbidden', 'Matrix runtime write is not authorized');
|
||||
}
|
||||
|
||||
private async assertWriteAuthorized(
|
||||
operation: 'session.send' | 'session.terminate',
|
||||
sessionId: string,
|
||||
scope: RuntimeScope,
|
||||
approvalRef?: string,
|
||||
): Promise<void> {
|
||||
await this.writeAuthority.assertAuthorized({
|
||||
operation,
|
||||
sessionId,
|
||||
scope,
|
||||
...(approvalRef ? { approvalRef } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
private pruneExpired(nowMs: number): void {
|
||||
for (const [id, attachment] of this.attachments) {
|
||||
if (attachment.expiresAtMs <= nowMs) this.attachments.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotScope(scope: RuntimeScope): RuntimeScope {
|
||||
return Object.freeze({ ...scope });
|
||||
}
|
||||
|
||||
function sameScope(left: RuntimeScope, right: RuntimeScope): boolean {
|
||||
return (
|
||||
left.actorId === right.actorId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.channelId === right.channelId &&
|
||||
left.correlationId === right.correlationId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AgentRuntimeProvider, RuntimeScope } from '@mosaicstack/types';
|
||||
import {
|
||||
MatrixNativeRuntimeProvider,
|
||||
type MatrixRuntimeTransport,
|
||||
} from './matrix-native-runtime-provider.js';
|
||||
import {
|
||||
TmuxFleetRuntimeProvider,
|
||||
type FleetRuntimeTransport,
|
||||
} from './tmux-fleet-runtime-provider.js';
|
||||
|
||||
const scope: RuntimeScope = {
|
||||
actorId: 'operator-1',
|
||||
tenantId: 'tenant-a',
|
||||
channelId: 'cli',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
interface ProviderFixture {
|
||||
name: string;
|
||||
provider: AgentRuntimeProvider;
|
||||
providerId: string;
|
||||
verifySession: unknown;
|
||||
send: unknown;
|
||||
}
|
||||
|
||||
function fixtures(): ProviderFixture[] {
|
||||
const fleetTransport: FleetRuntimeTransport = {
|
||||
verifySession: vi.fn(async () => ({
|
||||
id: 'session-1',
|
||||
runtimeId: 'native-1',
|
||||
socketName: 'fleet',
|
||||
})),
|
||||
listSessions: vi.fn(async () => [
|
||||
{ id: 'session-1', runtimeId: 'native-1', socketName: 'fleet' },
|
||||
]),
|
||||
sendMessage: vi.fn(async () => undefined),
|
||||
terminate: vi.fn(async () => undefined),
|
||||
};
|
||||
const fleet = new TmuxFleetRuntimeProvider({
|
||||
transport: fleetTransport,
|
||||
readAuthority: { canRead: vi.fn(async () => true) },
|
||||
writeAuthority: {
|
||||
canWrite: vi.fn(async () => true),
|
||||
assertAuthorized: vi.fn(async () => undefined),
|
||||
},
|
||||
attachmentIdFactory: () => 'attachment-1',
|
||||
now: () => new Date('2026-07-13T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const matrixTransport: MatrixRuntimeTransport = {
|
||||
health: vi.fn(async () => ({
|
||||
status: 'healthy' as const,
|
||||
checkedAt: '2026-07-13T00:00:00.000Z',
|
||||
})),
|
||||
verifySession: vi.fn(async () => ({
|
||||
id: 'session-1',
|
||||
runtimeId: 'native-1',
|
||||
state: 'active' as const,
|
||||
createdAt: '2026-07-13T00:00:00.000Z',
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
})),
|
||||
listSessions: vi.fn(async () => [
|
||||
{
|
||||
id: 'session-1',
|
||||
runtimeId: 'native-1',
|
||||
state: 'active' as const,
|
||||
createdAt: '2026-07-13T00:00:00.000Z',
|
||||
updatedAt: '2026-07-13T00:00:00.000Z',
|
||||
},
|
||||
]),
|
||||
stream: async function* () {},
|
||||
send: vi.fn(async () => undefined),
|
||||
terminate: vi.fn(async () => undefined),
|
||||
};
|
||||
const matrix = new MatrixNativeRuntimeProvider({
|
||||
transport: matrixTransport,
|
||||
readAuthority: { canRead: vi.fn(async () => true) },
|
||||
writeAuthority: {
|
||||
canWrite: vi.fn(async () => true),
|
||||
assertAuthorized: vi.fn(async () => undefined),
|
||||
},
|
||||
attachmentIdFactory: () => 'attachment-1',
|
||||
now: () => new Date('2026-07-13T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'tmux/fleet',
|
||||
provider: fleet,
|
||||
providerId: 'fleet.tmux',
|
||||
verifySession: fleetTransport.verifySession,
|
||||
send: fleetTransport.sendMessage,
|
||||
},
|
||||
{
|
||||
name: 'Matrix/native',
|
||||
provider: matrix,
|
||||
providerId: 'runtime.matrix',
|
||||
verifySession: matrixTransport.verifySession,
|
||||
send: matrixTransport.send,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** Shared contract tests for the migration-safe provider intersection. */
|
||||
describe('tmux/fleet and Matrix/native provider parity', (): void => {
|
||||
it.each(fixtures())(
|
||||
'%s exposes the shared runtime operations',
|
||||
async (fixture): Promise<void> => {
|
||||
await expect(fixture.provider.capabilities(scope)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
supported: expect.arrayContaining([
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
]),
|
||||
}),
|
||||
);
|
||||
await expect(fixture.provider.listSessions(scope)).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'session-1',
|
||||
providerId: fixture.providerId,
|
||||
runtimeId: 'native-1',
|
||||
}),
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(fixtures())(
|
||||
'%s rejects empty messages before touching its transport',
|
||||
async (fixture): Promise<void> => {
|
||||
await expect(
|
||||
fixture.provider.sendMessage(
|
||||
'session-1',
|
||||
{ content: '', idempotencyKey: 'message-1' },
|
||||
scope,
|
||||
),
|
||||
).rejects.toMatchObject({ code: 'invalid_request' });
|
||||
expect(fixture.verifySession).not.toHaveBeenCalled();
|
||||
expect(fixture.send).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(fixtures())(
|
||||
'%s rejects an empty termination approval before touching its transport',
|
||||
async (fixture): Promise<void> => {
|
||||
await expect(fixture.provider.terminate('session-1', '', scope)).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
});
|
||||
expect(fixture.verifySession).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(fixtures())(
|
||||
'%s creates a read-only handle bound to immutable scope',
|
||||
async (fixture): Promise<void> => {
|
||||
await expect(fixture.provider.attach('session-1', 'control', scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
});
|
||||
expect(fixture.verifySession).not.toHaveBeenCalled();
|
||||
|
||||
await expect(fixture.provider.attach('session-1', 'read', scope)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
attachmentId: 'attachment-1',
|
||||
sessionId: 'session-1',
|
||||
mode: 'read',
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
fixture.provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }),
|
||||
).rejects.toMatchObject({ code: 'forbidden' });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { RuntimeScope } from '@mosaicstack/types';
|
||||
import {
|
||||
type FleetReadAuthority,
|
||||
type FleetWriteAuthority,
|
||||
TmuxFleetRuntimeProvider,
|
||||
} from './tmux-fleet-runtime-provider.js';
|
||||
import type {
|
||||
FleetRuntimeProviderError,
|
||||
FleetRuntimeTarget,
|
||||
FleetRuntimeTransport,
|
||||
} from './tmux-fleet-runtime-provider.js';
|
||||
|
||||
const scope: RuntimeScope = {
|
||||
actorId: 'operator-1',
|
||||
tenantId: 'tenant-a',
|
||||
channelId: 'discord-1',
|
||||
correlationId: 'corr-1',
|
||||
};
|
||||
|
||||
const target: FleetRuntimeTarget = {
|
||||
id: 'coder0',
|
||||
runtimeId: 'codex',
|
||||
socketName: 'tess-fleet',
|
||||
};
|
||||
|
||||
function transport(): FleetRuntimeTransport {
|
||||
return {
|
||||
verifySession: vi.fn(async (): Promise<FleetRuntimeTarget> => target),
|
||||
listSessions: vi.fn(async (): Promise<FleetRuntimeTarget[]> => [target]),
|
||||
sendMessage: vi.fn(async (): Promise<void> => undefined),
|
||||
terminate: vi.fn(async (): Promise<void> => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
function readAuthority(): FleetReadAuthority {
|
||||
return { canRead: vi.fn(async (): Promise<boolean> => true) };
|
||||
}
|
||||
|
||||
describe('TmuxFleetRuntimeProvider security policy', (): void => {
|
||||
it('advertises only fleet operations it can safely implement', async (): Promise<void> => {
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: transport() });
|
||||
|
||||
await expect(provider.capabilities(scope)).resolves.toEqual({
|
||||
supported: [
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects control attach without consulting the tmux transport', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
|
||||
|
||||
await expect(provider.attach('coder0', 'control', scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
expect(fleet.verifySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies fleet listing without an exact-scope read authority decision', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
|
||||
|
||||
await expect(provider.listSessions(scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
expect(fleet.listSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies read attachment without an exact-scope read authority decision', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
|
||||
|
||||
await expect(provider.attach('coder0', 'read', scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
expect(fleet.verifySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a read-only attachment only after exact target verification', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: fleet,
|
||||
readAuthority: readAuthority(),
|
||||
attachmentIdFactory: (): string => 'attachment-1',
|
||||
now: (): Date => new Date('2026-07-12T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
await expect(provider.attach('coder0', 'read', scope)).resolves.toEqual({
|
||||
attachmentId: 'attachment-1',
|
||||
sessionId: 'coder0',
|
||||
mode: 'read',
|
||||
expiresAt: '2026-07-12T00:05:00.000Z',
|
||||
});
|
||||
expect(fleet.verifySession).toHaveBeenCalledWith('coder0');
|
||||
});
|
||||
|
||||
it('denies attachment-handle replay from another immutable actor scope', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: fleet,
|
||||
readAuthority: readAuthority(),
|
||||
attachmentIdFactory: (): string => 'attachment-1',
|
||||
now: (): Date => new Date('2026-07-12T00:00:00.000Z'),
|
||||
});
|
||||
await provider.attach('coder0', 'read', scope);
|
||||
|
||||
await expect(
|
||||
provider.detach('attachment-1', { ...scope, actorId: 'operator-2' }),
|
||||
).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
});
|
||||
|
||||
it('keeps attachment scope immutable after caller-side scope mutation', async (): Promise<void> => {
|
||||
const mutableScope = { ...scope };
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: transport(),
|
||||
readAuthority: readAuthority(),
|
||||
attachmentIdFactory: (): string => 'attachment-1',
|
||||
now: (): Date => new Date('2026-07-12T00:00:00.000Z'),
|
||||
});
|
||||
await provider.attach('coder0', 'read', mutableScope);
|
||||
mutableScope.actorId = 'operator-2';
|
||||
|
||||
await expect(provider.detach('attachment-1', scope)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('denies expired attachment handles and removes them', async (): Promise<void> => {
|
||||
let now = new Date('2026-07-12T00:00:00.000Z');
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: transport(),
|
||||
readAuthority: readAuthority(),
|
||||
attachmentIdFactory: (): string => 'attachment-1',
|
||||
now: (): Date => now,
|
||||
});
|
||||
await provider.attach('coder0', 'read', scope);
|
||||
now = new Date('2026-07-12T00:05:00.001Z');
|
||||
|
||||
await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
});
|
||||
|
||||
it('prunes expired attachment handles before creating a new handle', async (): Promise<void> => {
|
||||
let now = new Date('2026-07-12T00:00:00.000Z');
|
||||
let attachmentSequence = 0;
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: transport(),
|
||||
readAuthority: readAuthority(),
|
||||
attachmentIdFactory: (): string => `attachment-${++attachmentSequence}`,
|
||||
now: (): Date => now,
|
||||
});
|
||||
await provider.attach('coder0', 'read', scope);
|
||||
now = new Date('2026-07-12T00:05:00.001Z');
|
||||
await provider.attach('coder0', 'read', scope);
|
||||
|
||||
await expect(provider.detach('attachment-1', scope)).rejects.toMatchObject({
|
||||
code: 'not_found',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
});
|
||||
|
||||
it('rejects an empty message before consulting write authority or tmux', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const writeAuthority: FleetWriteAuthority = {
|
||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
||||
};
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
|
||||
|
||||
await expect(
|
||||
provider.sendMessage('coder0', { content: '', idempotencyKey: 'message-1' }, scope),
|
||||
).rejects.toMatchObject({
|
||||
code: 'invalid_request',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
expect(writeAuthority.canWrite).not.toHaveBeenCalled();
|
||||
expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled();
|
||||
expect(fleet.verifySession).not.toHaveBeenCalled();
|
||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies fleet writes by default before probing the tmux transport', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet });
|
||||
|
||||
await expect(
|
||||
provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope),
|
||||
).rejects.toMatchObject({ code: 'forbidden' } satisfies Partial<FleetRuntimeProviderError>);
|
||||
await expect(provider.terminate('coder0', 'approval-1', scope)).rejects.toMatchObject({
|
||||
code: 'forbidden',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
expect(fleet.verifySession).not.toHaveBeenCalled();
|
||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
||||
expect(fleet.terminate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects an unverified target before consulting orchestrator write authority', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
fleet.verifySession = vi.fn(async (): Promise<FleetRuntimeTarget> => {
|
||||
throw new Error('target identity mismatch');
|
||||
});
|
||||
const writeAuthority: FleetWriteAuthority = {
|
||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
||||
};
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
|
||||
|
||||
await expect(
|
||||
provider.sendMessage('coder', { content: 'hello', idempotencyKey: 'message-1' }, scope),
|
||||
).rejects.toThrow('target identity mismatch');
|
||||
expect(writeAuthority.assertAuthorized).not.toHaveBeenCalled();
|
||||
expect(fleet.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the role-neutral interaction source label by default', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const writeAuthority: FleetWriteAuthority = {
|
||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
||||
};
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: fleet, writeAuthority });
|
||||
|
||||
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
||||
|
||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'interaction');
|
||||
});
|
||||
|
||||
it('passes an exact session ID to the fleet transport only through orchestrator-authorized writes', async (): Promise<void> => {
|
||||
const fleet = transport();
|
||||
const writeAuthority: FleetWriteAuthority = {
|
||||
canWrite: vi.fn(async (): Promise<boolean> => true),
|
||||
assertAuthorized: vi.fn(async (): Promise<void> => undefined),
|
||||
};
|
||||
const provider = new TmuxFleetRuntimeProvider({
|
||||
transport: fleet,
|
||||
sourceLabel: 'operator',
|
||||
writeAuthority,
|
||||
});
|
||||
|
||||
await provider.sendMessage('coder0', { content: 'hello', idempotencyKey: 'message-1' }, scope);
|
||||
await provider.terminate('coder0', 'approval-1', scope);
|
||||
|
||||
expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({
|
||||
operation: 'session.send',
|
||||
sessionId: 'coder0',
|
||||
scope,
|
||||
});
|
||||
expect(writeAuthority.assertAuthorized).toHaveBeenCalledWith({
|
||||
operation: 'session.terminate',
|
||||
sessionId: 'coder0',
|
||||
scope,
|
||||
approvalRef: 'approval-1',
|
||||
});
|
||||
expect(fleet.sendMessage).toHaveBeenCalledWith('coder0', 'hello', 'operator');
|
||||
expect(fleet.terminate).toHaveBeenCalledWith('coder0');
|
||||
});
|
||||
|
||||
it('fails closed when consumers ask for session streaming', async (): Promise<void> => {
|
||||
const provider = new TmuxFleetRuntimeProvider({ transport: transport() });
|
||||
const stream = provider.streamSession('coder0', undefined, scope)[Symbol.asyncIterator]();
|
||||
|
||||
await expect(stream.next()).rejects.toMatchObject({
|
||||
code: 'capability_unsupported',
|
||||
} satisfies Partial<FleetRuntimeProviderError>);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type {
|
||||
AgentRuntimeProvider,
|
||||
RuntimeAttachHandle,
|
||||
RuntimeAttachMode,
|
||||
RuntimeCapabilitySet,
|
||||
RuntimeHealth,
|
||||
RuntimeMessage,
|
||||
RuntimeScope,
|
||||
RuntimeSession,
|
||||
RuntimeSessionTree,
|
||||
RuntimeStreamEvent,
|
||||
} from '@mosaicstack/types';
|
||||
const FLEET_PROVIDER_ID = 'fleet.tmux';
|
||||
const ATTACHMENT_TTL_MS = 5 * 60 * 1_000;
|
||||
|
||||
export type FleetRuntimeProviderErrorCode =
|
||||
| 'capability_unsupported'
|
||||
| 'forbidden'
|
||||
| 'invalid_request'
|
||||
| 'not_found';
|
||||
|
||||
/** A roster-bound target verified by the concrete fleet transport. */
|
||||
export interface FleetRuntimeTarget {
|
||||
id: string;
|
||||
runtimeId: string;
|
||||
socketName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow transport boundary implemented by the Mosaic tmux adapter. Keeping it
|
||||
* here prevents the runtime package from depending on the Mosaic CLI package.
|
||||
*/
|
||||
export interface FleetRuntimeTransport {
|
||||
verifySession(sessionId: string): Promise<FleetRuntimeTarget>;
|
||||
listSessions(): Promise<FleetRuntimeTarget[]>;
|
||||
sendMessage(sessionId: string, message: string, sourceLabel: string): Promise<void>;
|
||||
terminate(sessionId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type FleetReadOperation =
|
||||
| 'runtime.health'
|
||||
| 'session.list'
|
||||
| 'session.tree'
|
||||
| 'session.attach';
|
||||
|
||||
export interface FleetReadAuthorization {
|
||||
operation: FleetReadOperation;
|
||||
scope: RuntimeScope;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/** Authorization for fleet inspection and read-only attachments. */
|
||||
export interface FleetReadAuthority {
|
||||
canRead(authorization: FleetReadAuthorization): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface FleetWriteAuthorization {
|
||||
operation: 'session.send' | 'session.terminate';
|
||||
sessionId: string;
|
||||
scope: RuntimeScope;
|
||||
/** Present only for terminate; authority adapters bind it to the exact action. */
|
||||
approvalRef?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The orchestrator is the only authority that may permit interaction-plane write/control requests to a
|
||||
* fleet peer. Gateway records the request and denial/success around provider
|
||||
* invocation; the default authority prevents direct interaction-plane writes by design.
|
||||
*/
|
||||
export interface FleetWriteAuthority {
|
||||
/** Non-consuming preflight used before probing the fleet transport. */
|
||||
canWrite(authorization: FleetWriteAuthorization): Promise<boolean>;
|
||||
/** Final exact-target authorization; may consume an orchestrator grant. */
|
||||
assertAuthorized(authorization: FleetWriteAuthorization): Promise<void>;
|
||||
}
|
||||
|
||||
export interface TmuxFleetRuntimeProviderOptions {
|
||||
transport: FleetRuntimeTransport;
|
||||
readAuthority?: FleetReadAuthority;
|
||||
writeAuthority?: FleetWriteAuthority;
|
||||
sourceLabel?: string;
|
||||
attachmentIdFactory?: () => string;
|
||||
now?: () => Date;
|
||||
attachmentTtlMs?: number;
|
||||
}
|
||||
|
||||
interface FleetAttachment {
|
||||
sessionId: string;
|
||||
scope: RuntimeScope;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
/** A typed, fail-closed provider error that callers can normalize at the boundary. */
|
||||
export class FleetRuntimeProviderError extends Error {
|
||||
constructor(
|
||||
readonly code: FleetRuntimeProviderErrorCode,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = FleetRuntimeProviderError.name;
|
||||
}
|
||||
}
|
||||
|
||||
class DenyFleetReadAuthority implements FleetReadAuthority {
|
||||
async canRead(_authorization: FleetReadAuthorization): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class DenyFleetWriteAuthority implements FleetWriteAuthority {
|
||||
async canWrite(_authorization: FleetWriteAuthorization): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
async assertAuthorized(_authorization: FleetWriteAuthorization): Promise<void> {
|
||||
throw new FleetRuntimeProviderError(
|
||||
'forbidden',
|
||||
'Fleet writes require an explicit orchestrator authority decision',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A capability-limited provider for rostered local fleet peers. It never
|
||||
* permits raw tmux socket/target selection, interactive control attach, or
|
||||
* direct interaction-plane writes; all side effects pass through exact transport checks.
|
||||
*/
|
||||
export class TmuxFleetRuntimeProvider implements AgentRuntimeProvider {
|
||||
readonly id = FLEET_PROVIDER_ID;
|
||||
private readonly attachments = new Map<string, FleetAttachment>();
|
||||
private readonly readAuthority: FleetReadAuthority;
|
||||
private readonly writeAuthority: FleetWriteAuthority;
|
||||
private readonly sourceLabel: string;
|
||||
private readonly attachmentIdFactory: () => string;
|
||||
private readonly now: () => Date;
|
||||
private readonly attachmentTtlMs: number;
|
||||
|
||||
constructor(private readonly options: TmuxFleetRuntimeProviderOptions) {
|
||||
this.readAuthority = options.readAuthority ?? new DenyFleetReadAuthority();
|
||||
this.writeAuthority = options.writeAuthority ?? new DenyFleetWriteAuthority();
|
||||
this.sourceLabel = options.sourceLabel ?? 'interaction';
|
||||
this.attachmentIdFactory = options.attachmentIdFactory ?? randomUUID;
|
||||
this.now = options.now ?? (() => new Date());
|
||||
this.attachmentTtlMs = options.attachmentTtlMs ?? ATTACHMENT_TTL_MS;
|
||||
}
|
||||
|
||||
async capabilities(_scope: RuntimeScope): Promise<RuntimeCapabilitySet> {
|
||||
return {
|
||||
supported: [
|
||||
'session.list',
|
||||
'session.tree',
|
||||
'session.send',
|
||||
'session.attach',
|
||||
'session.terminate',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async health(scope: RuntimeScope): Promise<RuntimeHealth> {
|
||||
const targets = await this.readTargets('runtime.health', scope);
|
||||
return {
|
||||
status: targets.length > 0 ? 'healthy' : 'down',
|
||||
checkedAt: this.now().toISOString(),
|
||||
detail:
|
||||
targets.length > 0
|
||||
? 'Authorized rostered fleet peers are reachable'
|
||||
: 'No authorized rostered fleet peers are reachable',
|
||||
};
|
||||
}
|
||||
|
||||
async listSessions(scope: RuntimeScope): Promise<RuntimeSession[]> {
|
||||
const targets = await this.readTargets('session.list', scope);
|
||||
return this.toRuntimeSessions(targets);
|
||||
}
|
||||
|
||||
async getSessionTree(scope: RuntimeScope): Promise<RuntimeSessionTree[]> {
|
||||
const targets = await this.readTargets('session.tree', scope);
|
||||
return this.toRuntimeSessions(targets).map(
|
||||
(session): RuntimeSessionTree => ({
|
||||
session,
|
||||
children: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async *streamSession(
|
||||
_sessionId: string,
|
||||
_cursor: string | undefined,
|
||||
_scope: RuntimeScope,
|
||||
): AsyncIterable<RuntimeStreamEvent> {
|
||||
throw new FleetRuntimeProviderError(
|
||||
'capability_unsupported',
|
||||
'Fleet session streaming is not supported by the tmux provider',
|
||||
);
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
sessionId: string,
|
||||
message: RuntimeMessage,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
if (message.content.length === 0) {
|
||||
throw new FleetRuntimeProviderError('invalid_request', 'Fleet message content is required');
|
||||
}
|
||||
await this.assertWritePermitted('session.send', sessionId, scope);
|
||||
const target = await this.options.transport.verifySession(sessionId);
|
||||
await this.assertWriteAuthorized('session.send', target.id, scope);
|
||||
await this.options.transport.sendMessage(target.id, message.content, this.sourceLabel);
|
||||
}
|
||||
|
||||
async attach(
|
||||
sessionId: string,
|
||||
mode: RuntimeAttachMode,
|
||||
scope: RuntimeScope,
|
||||
): Promise<RuntimeAttachHandle> {
|
||||
if (mode !== 'read') {
|
||||
throw new FleetRuntimeProviderError('forbidden', 'Fleet control attach is not permitted');
|
||||
}
|
||||
await this.assertReadAuthorized('session.attach', sessionId, scope);
|
||||
const target = await this.options.transport.verifySession(sessionId);
|
||||
await this.assertReadAuthorized('session.attach', target.id, scope);
|
||||
const attachmentId = this.attachmentIdFactory();
|
||||
const nowMs = this.now().getTime();
|
||||
this.pruneExpiredAttachments(nowMs);
|
||||
const expiresAtMs = nowMs + this.attachmentTtlMs;
|
||||
this.attachments.set(attachmentId, {
|
||||
sessionId: target.id,
|
||||
scope: snapshotScope(scope),
|
||||
expiresAtMs,
|
||||
});
|
||||
return {
|
||||
attachmentId,
|
||||
sessionId: target.id,
|
||||
mode,
|
||||
expiresAt: new Date(expiresAtMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async detach(attachmentId: string, scope: RuntimeScope): Promise<void> {
|
||||
const attachment = this.attachments.get(attachmentId);
|
||||
if (!attachment) {
|
||||
throw new FleetRuntimeProviderError('not_found', 'Fleet attachment is not active');
|
||||
}
|
||||
if (this.now().getTime() >= attachment.expiresAtMs) {
|
||||
this.attachments.delete(attachmentId);
|
||||
throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment has expired');
|
||||
}
|
||||
if (!sameScope(attachment.scope, scope)) {
|
||||
throw new FleetRuntimeProviderError('forbidden', 'Fleet attachment scope does not match');
|
||||
}
|
||||
this.attachments.delete(attachmentId);
|
||||
}
|
||||
|
||||
async terminate(sessionId: string, approvalRef: string, scope: RuntimeScope): Promise<void> {
|
||||
if (approvalRef.trim().length === 0) {
|
||||
throw new FleetRuntimeProviderError(
|
||||
'invalid_request',
|
||||
'Fleet termination approval is required',
|
||||
);
|
||||
}
|
||||
await this.assertWritePermitted('session.terminate', sessionId, scope, approvalRef);
|
||||
const target = await this.options.transport.verifySession(sessionId);
|
||||
await this.assertWriteAuthorized('session.terminate', target.id, scope, approvalRef);
|
||||
await this.options.transport.terminate(target.id);
|
||||
}
|
||||
|
||||
private async readTargets(
|
||||
operation: FleetReadOperation,
|
||||
scope: RuntimeScope,
|
||||
): Promise<FleetRuntimeTarget[]> {
|
||||
await this.assertReadAuthorized(operation, undefined, scope);
|
||||
const targets = await this.options.transport.listSessions();
|
||||
const authorization = await Promise.all(
|
||||
targets.map(
|
||||
async (target): Promise<boolean> =>
|
||||
this.readAuthority.canRead({ operation, sessionId: target.id, scope }),
|
||||
),
|
||||
);
|
||||
return targets.filter((_target, index): boolean => authorization[index] === true);
|
||||
}
|
||||
|
||||
private toRuntimeSessions(targets: FleetRuntimeTarget[]): RuntimeSession[] {
|
||||
const timestamp = this.now().toISOString();
|
||||
return targets.map(
|
||||
(target): RuntimeSession => ({
|
||||
id: target.id,
|
||||
providerId: this.id,
|
||||
runtimeId: target.runtimeId,
|
||||
state: 'active',
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async assertReadAuthorized(
|
||||
operation: FleetReadOperation,
|
||||
sessionId: string | undefined,
|
||||
scope: RuntimeScope,
|
||||
): Promise<void> {
|
||||
const allowed = await this.readAuthority.canRead({
|
||||
operation,
|
||||
scope,
|
||||
...(sessionId ? { sessionId } : {}),
|
||||
});
|
||||
if (!allowed) {
|
||||
throw new FleetRuntimeProviderError('forbidden', 'Fleet read is not authorized');
|
||||
}
|
||||
}
|
||||
|
||||
private pruneExpiredAttachments(nowMs: number): void {
|
||||
for (const [attachmentId, attachment] of this.attachments) {
|
||||
if (attachment.expiresAtMs <= nowMs) {
|
||||
this.attachments.delete(attachmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWritePermitted(
|
||||
operation: FleetWriteAuthorization['operation'],
|
||||
sessionId: string,
|
||||
scope: RuntimeScope,
|
||||
approvalRef?: string,
|
||||
): Promise<void> {
|
||||
const permitted = await this.writeAuthority.canWrite({
|
||||
operation,
|
||||
sessionId,
|
||||
scope,
|
||||
...(approvalRef ? { approvalRef } : {}),
|
||||
});
|
||||
if (!permitted) {
|
||||
throw new FleetRuntimeProviderError('forbidden', 'Fleet write is not authorized');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertWriteAuthorized(
|
||||
operation: FleetWriteAuthorization['operation'],
|
||||
sessionId: string,
|
||||
scope: RuntimeScope,
|
||||
approvalRef?: string,
|
||||
): Promise<void> {
|
||||
await this.writeAuthority.assertAuthorized({
|
||||
operation,
|
||||
sessionId,
|
||||
scope,
|
||||
...(approvalRef ? { approvalRef } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function snapshotScope(scope: RuntimeScope): RuntimeScope {
|
||||
return Object.freeze({
|
||||
actorId: scope.actorId,
|
||||
tenantId: scope.tenantId,
|
||||
channelId: scope.channelId,
|
||||
correlationId: scope.correlationId,
|
||||
});
|
||||
}
|
||||
|
||||
function sameScope(left: RuntimeScope, right: RuntimeScope): boolean {
|
||||
return (
|
||||
left.actorId === right.actorId &&
|
||||
left.tenantId === right.tenantId &&
|
||||
left.channelId === right.channelId &&
|
||||
left.correlationId === right.correlationId
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user