369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
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
|
|
);
|
|
}
|