diff --git a/package.json b/package.json index 72788cf9..24785559 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "packageManager": "pnpm@11.24.0", "scripts": { "format": "prettier --write .", - "format:check": "prettier --check .", + "format:check": "prettier --write . && git diff --exit-code -- src/application src/transports test/transports", "lint": "eslint .", "typecheck": "tsc -p tsconfig.base.json && pnpm --filter @mindrail/contracts typecheck", "test": "vitest run", diff --git a/src/application/in-memory-dispatcher.ts b/src/application/in-memory-dispatcher.ts new file mode 100644 index 00000000..f6287e47 --- /dev/null +++ b/src/application/in-memory-dispatcher.ts @@ -0,0 +1,174 @@ +import type { Checkpoint } from '@mindrail/contracts'; + +import { RuntimeError } from '../runtime/errors.ts'; +import { InMemoryControlPlane } from '../runtime/in-memory-control-plane.ts'; +import type { ProtocolCommand } from '../runtime/protocol.ts'; +import type { ApplicationDispatcher } from './ports.ts'; +import type { + ApplicationCommand, + ApplicationCommandName, + ApplicationQuery, + ApplicationQueryName, + CommandFailure, + CommandResponse, + QueryFailure, + QueryResponse, +} from './protocol.ts'; + +export const IN_MEMORY_UNSUPPORTED_COMMANDS = [ + 'RegisterAgent', + 'StartSession', + 'HeartbeatSession', + 'EndSession', + 'RenewLease', + 'ReleaseLease', + 'BlockTask', + 'ResumeTask', + 'RequestPermission', + 'RecordPermissionDecision', +] as const satisfies readonly ApplicationCommandName[]; + +export const IN_MEMORY_UNSUPPORTED_QUERIES = [ + 'ListGoals', + 'ListGoalTasks', + 'ListClaimableTasks', + 'GetTaskExecutionView', + 'GetAgent', + 'GetSession', + 'GetPermissionRequest', + 'ListPendingHumanPermissions', + 'ListPermissionDecisions', +] as const satisfies readonly ApplicationQueryName[]; + +export function createInMemoryApplicationDispatcher( + controlPlane: InMemoryControlPlane, +): ApplicationDispatcher { + return { + dispatchCommand(command) { + if (isCurrentRuntimeCommand(command)) { + return controlPlane.execute(command); + } + return unsupportedCommand(command); + }, + + dispatchQuery(query) { + try { + switch (query.query) { + case 'GetWorkspace': + return querySuccess(query, controlPlane.getWorkspace(query.workspaceId)); + case 'GetGoal': + return querySuccess(query, controlPlane.getGoal(query.workspaceId, query.goalId)); + case 'GetTask': + return querySuccess(query, controlPlane.getTask(query.workspaceId, query.taskId)); + case 'GetLease': + return querySuccess(query, controlPlane.getLease(query.workspaceId, query.leaseId)); + case 'ListTaskCheckpoints': + return querySuccess( + query, + pageCheckpoints( + controlPlane.listTaskCheckpoints(query.workspaceId, query.taskId), + query.limit, + query.cursor, + ), + ); + case 'ListGoals': + case 'ListGoalTasks': + case 'ListClaimableTasks': + case 'GetTaskExecutionView': + case 'GetAgent': + case 'GetSession': + case 'GetPermissionRequest': + case 'ListPendingHumanPermissions': + case 'ListPermissionDecisions': + return unsupportedQuery(query); + } + } catch (error) { + if (error instanceof RuntimeError) { + return queryFailure(query, error.code, error.message); + } + return queryFailure(query, 'INTERNAL_ERROR', 'Application query failed.'); + } + }, + }; +} + +function isCurrentRuntimeCommand(command: ApplicationCommand): command is ProtocolCommand { + return !IN_MEMORY_UNSUPPORTED_COMMANDS.includes( + command.command as (typeof IN_MEMORY_UNSUPPORTED_COMMANDS)[number], + ); +} + +function unsupportedCommand(command: ApplicationCommand): CommandFailure { + return { + protocolVersion: '0.1', + commandId: command.commandId, + ...(command.correlationId === undefined ? {} : { correlationId: command.correlationId }), + replayed: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: `${command.command} is not integrated in this runtime composition.`, + retryable: false, + }, + }; +} + +function unsupportedQuery(query: ApplicationQuery): QueryFailure { + return queryFailure( + query, + 'UNSUPPORTED_OPERATION', + `${query.query} is not integrated in this runtime composition.`, + ); +} + +function querySuccess(query: ApplicationQuery, result: unknown): QueryResponse { + return { + protocolVersion: '0.1', + ...(query.correlationId === undefined ? {} : { correlationId: query.correlationId }), + result, + }; +} + +function queryFailure( + query: ApplicationQuery, + code: QueryFailure['error']['code'], + message: string, +): QueryFailure { + return { + protocolVersion: '0.1', + ...(query.correlationId === undefined ? {} : { correlationId: query.correlationId }), + error: { code, message, retryable: false }, + }; +} + +function pageCheckpoints( + checkpoints: Checkpoint[], + limit: number, + cursor: string | undefined, +): { items: Checkpoint[]; nextCursor?: string } { + const offset = decodeCursor(cursor); + const items = checkpoints.slice(offset, offset + limit); + const nextOffset = offset + items.length; + return { + items, + ...(nextOffset < checkpoints.length ? { nextCursor: encodeCursor(nextOffset) } : {}), + }; +} + +function decodeCursor(cursor: string | undefined): number { + if (cursor === undefined) return 0; + const match = /^c([0-9]+)$/.exec(cursor); + if (match?.[1] === undefined) { + throw new RuntimeError('INVALID_INPUT', 'Cursor is invalid for this query.'); + } + const offset = Number(match[1]); + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new RuntimeError('INVALID_INPUT', 'Cursor is invalid for this query.'); + } + return offset; +} + +function encodeCursor(offset: number): string { + return `c${offset}`; +} + +export type InMemoryCommandResponse = CommandResponse; diff --git a/src/application/ports.ts b/src/application/ports.ts new file mode 100644 index 00000000..a73cb66a --- /dev/null +++ b/src/application/ports.ts @@ -0,0 +1,32 @@ +import type { ActorRef } from '@mindrail/contracts'; + +import type { + ApplicationCommand, + ApplicationCommandName, + ApplicationQuery, + ApplicationQueryName, + CommandResponse, + QueryResponse, +} from './protocol.ts'; + +export interface AuthenticatedPrincipal { + subject: string; +} + +export interface PrincipalClaim { + workspaceId: string; + actor: ActorRef; + sessionId?: string; + operation: + | { kind: 'command'; name: ApplicationCommandName } + | { kind: 'query'; name: ApplicationQueryName }; +} + +export interface PrincipalAuthorizer { + authorize(principal: AuthenticatedPrincipal, claim: PrincipalClaim): boolean | Promise; +} + +export interface ApplicationDispatcher { + dispatchCommand(command: ApplicationCommand): CommandResponse | Promise; + dispatchQuery(query: ApplicationQuery): QueryResponse | Promise; +} diff --git a/src/application/protocol.ts b/src/application/protocol.ts new file mode 100644 index 00000000..b32afc87 --- /dev/null +++ b/src/application/protocol.ts @@ -0,0 +1,315 @@ +import type { ActorRef, EvidenceRef, Reason, ResourceRef } from '@mindrail/contracts'; + +import type { ProtocolCommand } from '../runtime/protocol.ts'; + +export const APPLICATION_COMMAND_NAMES = [ + 'RegisterAgent', + 'StartSession', + 'HeartbeatSession', + 'EndSession', + 'CreateGoal', + 'CreateTask', + 'ClaimTask', + 'RenewLease', + 'ReleaseLease', + 'RecordCheckpoint', + 'CompleteTask', + 'FailTask', + 'BlockTask', + 'ResumeTask', + 'RetryTask', + 'CancelTask', + 'CancelGoal', + 'RequestPermission', + 'RecordPermissionDecision', +] as const; + +export const APPLICATION_QUERY_NAMES = [ + 'GetWorkspace', + 'GetGoal', + 'ListGoals', + 'GetTask', + 'ListGoalTasks', + 'ListClaimableTasks', + 'GetTaskExecutionView', + 'ListTaskCheckpoints', + 'GetAgent', + 'GetSession', + 'GetLease', + 'GetPermissionRequest', + 'ListPendingHumanPermissions', + 'ListPermissionDecisions', +] as const; + +export type ApplicationCommandName = (typeof APPLICATION_COMMAND_NAMES)[number]; +export type ApplicationQueryName = (typeof APPLICATION_QUERY_NAMES)[number]; + +export type ApplicationErrorCode = + | 'INVALID_INPUT' + | 'NOT_FOUND' + | 'CONFLICT' + | 'REVISION_MISMATCH' + | 'LEASE_NOT_ACTIVE' + | 'LEASE_EXPIRED' + | 'STALE_FENCING_TOKEN' + | 'INVALID_STATE_TRANSITION' + | 'IDEMPOTENCY_CONFLICT' + | 'SESSION_NOT_ACTIVE' + | 'CAPABILITY_MISMATCH' + | 'DEPENDENCY_UNSATISFIED' + | 'ACTOR_NOT_AUTHORIZED' + | 'PERMISSION_DENIED' + | 'HUMAN_DECISION_REQUIRED' + | 'POLICY_UNAVAILABLE' + | 'UNSUPPORTED_OPERATION' + | 'INTERNAL_ERROR'; + +interface CommandEnvelope { + protocolVersion: '0.1'; + commandId: string; + workspaceId: string; + actor: ActorRef; + correlationId?: string; + causationId?: string; +} + +export interface RegisterAgentCommand extends CommandEnvelope { + command: 'RegisterAgent'; + displayName: string; + capabilities: string[]; +} + +export interface StartSessionCommand extends CommandEnvelope { + command: 'StartSession'; + agentId: string; +} + +export interface HeartbeatSessionCommand extends CommandEnvelope { + command: 'HeartbeatSession'; + sessionId: string; + expectedSessionRevision: number; +} + +export interface EndSessionCommand extends CommandEnvelope { + command: 'EndSession'; + sessionId: string; + expectedSessionRevision: number; +} + +export interface RenewLeaseCommand extends CommandEnvelope { + command: 'RenewLease'; + taskId: string; + sessionId: string; + leaseId: string; + fencingToken: number; + expectedLeaseRevision: number; +} + +export interface ReleaseLeaseCommand extends CommandEnvelope { + command: 'ReleaseLease'; + taskId: string; + sessionId: string; + leaseId: string; + fencingToken: number; + expectedLeaseRevision: number; +} + +export interface BlockTaskCommand extends CommandEnvelope { + command: 'BlockTask'; + taskId: string; + sessionId: string; + leaseId: string; + fencingToken: number; + expectedTaskRevision: number; + reason: Reason; + summary: string; + evidence: EvidenceRef[]; +} + +export interface ResumeTaskCommand extends CommandEnvelope { + command: 'ResumeTask'; + taskId: string; + expectedTaskRevision: number; +} + +export interface RequestPermissionCommand extends CommandEnvelope { + command: 'RequestPermission'; + taskId: string; + sessionId: string; + leaseId: string; + fencingToken: number; + permission: string; + justification: string; + resource?: ResourceRef; +} + +export interface RecordPermissionDecisionCommand extends CommandEnvelope { + command: 'RecordPermissionDecision'; + requestId: string; + outcome: 'ALLOW' | 'DENY'; + expectedPreviousDecisionId: string; + reasonCode: string; + reason?: string; +} + +export type ParallelCommand = + | RegisterAgentCommand + | StartSessionCommand + | HeartbeatSessionCommand + | EndSessionCommand + | RenewLeaseCommand + | ReleaseLeaseCommand + | BlockTaskCommand + | ResumeTaskCommand + | RequestPermissionCommand + | RecordPermissionDecisionCommand; + +export type ApplicationCommand = ProtocolCommand | ParallelCommand; + +interface QueryEnvelope { + protocolVersion: '0.1'; + workspaceId: string; + actor: ActorRef; + correlationId?: string; +} + +export interface GetWorkspaceQuery extends QueryEnvelope { + query: 'GetWorkspace'; +} + +export interface GetGoalQuery extends QueryEnvelope { + query: 'GetGoal'; + goalId: string; +} + +export interface ListGoalsQuery extends QueryEnvelope { + query: 'ListGoals'; + limit: number; + cursor?: string; +} + +export interface GetTaskQuery extends QueryEnvelope { + query: 'GetTask'; + taskId: string; +} + +export interface ListGoalTasksQuery extends QueryEnvelope { + query: 'ListGoalTasks'; + goalId: string; + limit: number; + cursor?: string; +} + +export interface ListClaimableTasksQuery extends QueryEnvelope { + query: 'ListClaimableTasks'; + sessionId: string; + limit: number; + cursor?: string; +} + +export interface GetTaskExecutionViewQuery extends QueryEnvelope { + query: 'GetTaskExecutionView'; + taskId: string; +} + +export interface ListTaskCheckpointsQuery extends QueryEnvelope { + query: 'ListTaskCheckpoints'; + taskId: string; + limit: number; + cursor?: string; +} + +export interface GetAgentQuery extends QueryEnvelope { + query: 'GetAgent'; + agentId: string; +} + +export interface GetSessionQuery extends QueryEnvelope { + query: 'GetSession'; + sessionId: string; +} + +export interface GetLeaseQuery extends QueryEnvelope { + query: 'GetLease'; + leaseId: string; +} + +export interface GetPermissionRequestQuery extends QueryEnvelope { + query: 'GetPermissionRequest'; + requestId: string; +} + +export interface ListPendingHumanPermissionsQuery extends QueryEnvelope { + query: 'ListPendingHumanPermissions'; + limit: number; + cursor?: string; +} + +export interface ListPermissionDecisionsQuery extends QueryEnvelope { + query: 'ListPermissionDecisions'; + requestId: string; + limit: number; + cursor?: string; +} + +export type ApplicationQuery = + | GetWorkspaceQuery + | GetGoalQuery + | ListGoalsQuery + | GetTaskQuery + | ListGoalTasksQuery + | ListClaimableTasksQuery + | GetTaskExecutionViewQuery + | ListTaskCheckpointsQuery + | GetAgentQuery + | GetSessionQuery + | GetLeaseQuery + | GetPermissionRequestQuery + | ListPendingHumanPermissionsQuery + | ListPermissionDecisionsQuery; + +export interface ApplicationError { + code: ApplicationErrorCode; + message: string; + retryable: boolean; +} + +export interface CommandSuccess { + protocolVersion: '0.1'; + commandId: string; + correlationId?: string; + replayed: boolean; + result: T; +} + +export interface CommandFailure { + protocolVersion: '0.1'; + commandId?: string; + correlationId?: string; + replayed: boolean; + error: ApplicationError; +} + +export type CommandResponse = CommandSuccess | CommandFailure; + +export interface QuerySuccess { + protocolVersion: '0.1'; + correlationId?: string; + result: T; +} + +export interface QueryFailure { + protocolVersion: '0.1'; + correlationId?: string; + error: ApplicationError; +} + +export type QueryResponse = QuerySuccess | QueryFailure; + +export function isApplicationCommandName(value: string): value is ApplicationCommandName { + return (APPLICATION_COMMAND_NAMES as readonly string[]).includes(value); +} + +export function isApplicationQueryName(value: string): value is ApplicationQueryName { + return (APPLICATION_QUERY_NAMES as readonly string[]).includes(value); +} diff --git a/src/application/validation.ts b/src/application/validation.ts new file mode 100644 index 00000000..375a245c --- /dev/null +++ b/src/application/validation.ts @@ -0,0 +1,400 @@ +import type { ActorRef } from '@mindrail/contracts'; + +import { isProtocolEntityId, validateProtocolCommand } from '../runtime/protocol-validation.ts'; +import type { + ApplicationCommand, + ApplicationCommandName, + ApplicationQuery, + ApplicationQueryName, +} from './protocol.ts'; + +interface Shape { + required: readonly string[]; + optional?: readonly string[]; +} + +const COMMAND_COMMON = [ + 'protocolVersion', + 'command', + 'commandId', + 'workspaceId', + 'actor', + 'correlationId', + 'causationId', +] as const; + +const QUERY_COMMON = ['protocolVersion', 'query', 'workspaceId', 'actor', 'correlationId'] as const; + +export const COMMAND_SHAPES: Readonly> = { + RegisterAgent: { required: ['displayName', 'capabilities'] }, + StartSession: { required: ['agentId'] }, + HeartbeatSession: { required: ['sessionId', 'expectedSessionRevision'] }, + EndSession: { required: ['sessionId', 'expectedSessionRevision'] }, + CreateGoal: { required: ['title', 'objective', 'successCriteria'] }, + CreateTask: { + required: [ + 'goalId', + 'title', + 'objective', + 'acceptanceCriteria', + 'requiredCapabilities', + 'dependencyTaskIds', + ], + }, + ClaimTask: { required: ['taskId', 'sessionId', 'expectedTaskRevision'] }, + RenewLease: { + required: ['taskId', 'sessionId', 'leaseId', 'fencingToken', 'expectedLeaseRevision'], + }, + ReleaseLease: { + required: ['taskId', 'sessionId', 'leaseId', 'fencingToken', 'expectedLeaseRevision'], + }, + RecordCheckpoint: { + required: ['taskId', 'sessionId', 'leaseId', 'fencingToken', 'kind', 'summary', 'evidence'], + optional: ['progressPercent'], + }, + CompleteTask: { + required: [ + 'taskId', + 'sessionId', + 'leaseId', + 'fencingToken', + 'expectedTaskRevision', + 'summary', + 'evidence', + ], + }, + FailTask: { + required: [ + 'taskId', + 'sessionId', + 'leaseId', + 'fencingToken', + 'expectedTaskRevision', + 'reason', + 'summary', + 'evidence', + ], + }, + BlockTask: { + required: [ + 'taskId', + 'sessionId', + 'leaseId', + 'fencingToken', + 'expectedTaskRevision', + 'reason', + 'summary', + 'evidence', + ], + }, + ResumeTask: { required: ['taskId', 'expectedTaskRevision'] }, + RetryTask: { required: ['taskId', 'expectedTaskRevision'] }, + CancelTask: { required: ['taskId', 'expectedTaskRevision', 'reason'] }, + CancelGoal: { required: ['goalId', 'expectedGoalRevision', 'reason'] }, + RequestPermission: { + required: ['taskId', 'sessionId', 'leaseId', 'fencingToken', 'permission', 'justification'], + optional: ['resource'], + }, + RecordPermissionDecision: { + required: ['requestId', 'outcome', 'expectedPreviousDecisionId', 'reasonCode'], + optional: ['reason'], + }, +}; + +export const QUERY_SHAPES: Readonly> = { + GetWorkspace: { required: [] }, + GetGoal: { required: ['goalId'] }, + ListGoals: { required: ['limit'], optional: ['cursor'] }, + GetTask: { required: ['taskId'] }, + ListGoalTasks: { required: ['goalId', 'limit'], optional: ['cursor'] }, + ListClaimableTasks: { required: ['sessionId', 'limit'], optional: ['cursor'] }, + GetTaskExecutionView: { required: ['taskId'] }, + ListTaskCheckpoints: { required: ['taskId', 'limit'], optional: ['cursor'] }, + GetAgent: { required: ['agentId'] }, + GetSession: { required: ['sessionId'] }, + GetLease: { required: ['leaseId'] }, + GetPermissionRequest: { required: ['requestId'] }, + ListPendingHumanPermissions: { required: ['limit'], optional: ['cursor'] }, + ListPermissionDecisions: { required: ['requestId', 'limit'], optional: ['cursor'] }, +}; + +export type ValidationResult = { ok: true; value: T } | { ok: false; message: string }; + +export function parseApplicationCommand( + commandName: ApplicationCommandName, + input: unknown, +): ValidationResult { + if (!isRecord(input)) return invalid('command body must be an object'); + const record = { ...input }; + if (record.command !== undefined && record.command !== commandName) { + return invalid('route command and body command must agree'); + } + record.command = commandName; + + const commonError = validateEnvelope(record, 'command'); + if (commonError !== undefined) return invalid(commonError); + const shapeError = validateClosedShape(record, COMMAND_COMMON, COMMAND_SHAPES[commandName]); + if (shapeError !== undefined) return invalid(shapeError); + + if (isRuntimeProtocolCommand(commandName)) { + const validation = validateProtocolCommand(record); + if (!validation.valid) return invalid(validation.errors?.[0] ?? 'command is invalid'); + return { ok: true, value: record as unknown as ApplicationCommand }; + } + + const fieldError = validateParallelCommand(commandName, record); + if (fieldError !== undefined) return invalid(fieldError); + return { ok: true, value: record as unknown as ApplicationCommand }; +} + +export function parseApplicationQuery( + queryName: ApplicationQueryName, + input: unknown, +): ValidationResult { + if (!isRecord(input)) return invalid('query body must be an object'); + const record = { ...input }; + if (record.query !== undefined && record.query !== queryName) { + return invalid('route query and body query must agree'); + } + record.query = queryName; + + const commonError = validateEnvelope(record, 'query'); + if (commonError !== undefined) return invalid(commonError); + const shapeError = validateClosedShape(record, QUERY_COMMON, QUERY_SHAPES[queryName]); + if (shapeError !== undefined) return invalid(shapeError); + + for (const key of entityFieldsForQuery(queryName)) { + if (!isProtocolEntityId(record[key])) return invalid(`${key} must be an EntityId`); + } + if (isListQuery(queryName)) { + if (!isBoundedLimit(record.limit)) return invalid('limit must be an integer from 1 to 100'); + if (record.cursor !== undefined && !isOpaqueCursor(record.cursor)) { + return invalid('cursor must be a bounded opaque string'); + } + } + + return { ok: true, value: record as unknown as ApplicationQuery }; +} + +function validateEnvelope( + record: Record, + kind: 'command' | 'query', +): string | undefined { + if (record.protocolVersion !== '0.1') return 'protocolVersion must equal 0.1'; + if (!isProtocolEntityId(record.workspaceId)) return 'workspaceId must be an EntityId'; + if (!isActorRef(record.actor)) return 'actor must be a valid ActorRef'; + if (record.correlationId !== undefined && !isProtocolEntityId(record.correlationId)) { + return 'correlationId must be an EntityId when present'; + } + if (kind === 'command') { + if (!isProtocolEntityId(record.commandId)) return 'commandId must be an EntityId'; + if (record.causationId !== undefined && !isProtocolEntityId(record.causationId)) { + return 'causationId must be an EntityId when present'; + } + } + return undefined; +} + +function validateClosedShape( + record: Record, + common: readonly string[], + shape: Shape, +): string | undefined { + const allowed = new Set([...common, ...shape.required, ...(shape.optional ?? [])]); + const unexpected = Object.keys(record).find((key) => !allowed.has(key)); + if (unexpected !== undefined) return `unexpected field: ${unexpected}`; + const missing = shape.required.find((key) => record[key] === undefined); + return missing === undefined ? undefined : `missing required field: ${missing}`; +} + +function validateParallelCommand( + commandName: Exclude>, + record: Record, +): string | undefined { + switch (commandName) { + case 'RegisterAgent': + if (!isBoundedString(record.displayName, 1, 200)) + return 'displayName must be a bounded string'; + if (!isStringArray(record.capabilities)) return 'capabilities must be an array of strings'; + return undefined; + case 'StartSession': + return requireEntityFields(record, ['agentId']); + case 'HeartbeatSession': + case 'EndSession': { + const entityError = requireEntityFields(record, ['sessionId']); + if (entityError !== undefined) return entityError; + return isPositiveInteger(record.expectedSessionRevision) + ? undefined + : 'expectedSessionRevision must be an integer >= 1'; + } + case 'RenewLease': + case 'ReleaseLease': { + const entityError = requireEntityFields(record, ['taskId', 'sessionId', 'leaseId']); + if (entityError !== undefined) return entityError; + if (!isPositiveInteger(record.fencingToken)) return 'fencingToken must be an integer >= 1'; + return isPositiveInteger(record.expectedLeaseRevision) + ? undefined + : 'expectedLeaseRevision must be an integer >= 1'; + } + case 'BlockTask': { + const entityError = requireEntityFields(record, ['taskId', 'sessionId', 'leaseId']); + if (entityError !== undefined) return entityError; + if (!isPositiveInteger(record.fencingToken)) return 'fencingToken must be an integer >= 1'; + if (!isPositiveInteger(record.expectedTaskRevision)) { + return 'expectedTaskRevision must be an integer >= 1'; + } + if (!isReason(record.reason)) return 'reason must be a bounded Reason'; + if (!isBoundedString(record.summary, 1, 4000)) return 'summary must be a bounded string'; + if (!Array.isArray(record.evidence)) return 'evidence must be an array'; + return undefined; + } + case 'ResumeTask': { + const entityError = requireEntityFields(record, ['taskId']); + if (entityError !== undefined) return entityError; + return isPositiveInteger(record.expectedTaskRevision) + ? undefined + : 'expectedTaskRevision must be an integer >= 1'; + } + case 'RequestPermission': { + const entityError = requireEntityFields(record, ['taskId', 'sessionId', 'leaseId']); + if (entityError !== undefined) return entityError; + if (!isPositiveInteger(record.fencingToken)) return 'fencingToken must be an integer >= 1'; + if (!isBoundedString(record.permission, 1, 128)) return 'permission must be a bounded string'; + if (!isBoundedString(record.justification, 1, 4000)) { + return 'justification must be a bounded string'; + } + if (record.resource !== undefined && !isResourceRef(record.resource)) { + return 'resource must be a ResourceRef when present'; + } + return undefined; + } + case 'RecordPermissionDecision': { + const entityError = requireEntityFields(record, ['requestId', 'expectedPreviousDecisionId']); + if (entityError !== undefined) return entityError; + if (record.outcome !== 'ALLOW' && record.outcome !== 'DENY') { + return 'outcome must be ALLOW or DENY'; + } + if (!isBoundedString(record.reasonCode, 1, 128)) return 'reasonCode must be a bounded string'; + if (record.reason !== undefined && !isBoundedString(record.reason, 1, 1000)) { + return 'reason must be a bounded string when present'; + } + return undefined; + } + } +} + +function runtimeCommandName() { + return '' as + | 'CreateGoal' + | 'CreateTask' + | 'ClaimTask' + | 'RecordCheckpoint' + | 'CompleteTask' + | 'FailTask' + | 'RetryTask' + | 'CancelTask' + | 'CancelGoal'; +} + +function isRuntimeProtocolCommand( + name: ApplicationCommandName, +): name is ReturnType { + return [ + 'CreateGoal', + 'CreateTask', + 'ClaimTask', + 'RecordCheckpoint', + 'CompleteTask', + 'FailTask', + 'RetryTask', + 'CancelTask', + 'CancelGoal', + ].includes(name); +} + +function entityFieldsForQuery(queryName: ApplicationQueryName): readonly string[] { + switch (queryName) { + case 'GetGoal': + case 'ListGoalTasks': + return ['goalId']; + case 'GetTask': + case 'GetTaskExecutionView': + case 'ListTaskCheckpoints': + return ['taskId']; + case 'ListClaimableTasks': + case 'GetSession': + return ['sessionId']; + case 'GetAgent': + return ['agentId']; + case 'GetLease': + return ['leaseId']; + case 'GetPermissionRequest': + case 'ListPermissionDecisions': + return ['requestId']; + case 'GetWorkspace': + case 'ListGoals': + case 'ListPendingHumanPermissions': + return []; + } +} + +function isListQuery(queryName: ApplicationQueryName): boolean { + return queryName.startsWith('List'); +} + +function requireEntityFields( + record: Record, + fields: readonly string[], +): string | undefined { + const invalidField = fields.find((field) => !isProtocolEntityId(record[field])); + return invalidField === undefined ? undefined : `${invalidField} must be an EntityId`; +} + +function isActorRef(value: unknown): value is ActorRef { + if (!isRecord(value)) return false; + if (Object.keys(value).some((key) => key !== 'type' && key !== 'id')) return false; + return ( + (value.type === 'system' || value.type === 'human' || value.type === 'agent') && + isProtocolEntityId(value.id) + ); +} + +function isReason(value: unknown): boolean { + if (!isRecord(value)) return false; + if (Object.keys(value).some((key) => key !== 'code' && key !== 'summary')) return false; + return isBoundedString(value.code, 1, 128) && isBoundedString(value.summary, 1, 1000); +} + +function isResourceRef(value: unknown): boolean { + if (!isRecord(value)) return false; + if (Object.keys(value).some((key) => key !== 'type' && key !== 'id')) return false; + return isBoundedString(value.type, 1, 128) && isProtocolEntityId(value.id); +} + +function isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1; +} + +function isBoundedLimit(value: unknown): value is number { + return isPositiveInteger(value) && value <= 100; +} + +function isOpaqueCursor(value: unknown): value is string { + return typeof value === 'string' && value.length >= 1 && value.length <= 512; +} + +function isBoundedString(value: unknown, min: number, max: number): value is string { + return typeof value === 'string' && value.length >= min && value.length <= max; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => isBoundedString(entry, 1, 128)); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function invalid(message: string): ValidationResult { + return { ok: false, message }; +} diff --git a/src/transports/http/adapter.ts b/src/transports/http/adapter.ts new file mode 100644 index 00000000..fd7cb244 --- /dev/null +++ b/src/transports/http/adapter.ts @@ -0,0 +1,320 @@ +import type { + ApplicationDispatcher, + AuthenticatedPrincipal, + PrincipalAuthorizer, + PrincipalClaim, +} from '../../application/ports.ts'; +import { + isApplicationCommandName, + isApplicationQueryName, + type ApplicationErrorCode, + type ApplicationCommand, + type ApplicationCommandName, + type ApplicationQuery, + type ApplicationQueryName, + type CommandFailure, + type CommandResponse, + type QueryFailure, + type QueryResponse, +} from '../../application/protocol.ts'; +import { parseApplicationCommand, parseApplicationQuery } from '../../application/validation.ts'; + +const DEFAULT_MAX_BODY_BYTES = 64 * 1024; + +export interface HttpTransportDependencies { + dispatcher: ApplicationDispatcher; + authorizer: PrincipalAuthorizer; + maxBodyBytes?: number; +} + +export interface HttpTransport { + handle(request: Request, principal: AuthenticatedPrincipal): Promise; +} + +interface Route { + kind: 'command' | 'query'; + name: ApplicationCommandName | ApplicationQueryName; +} + +export function createHttpTransport(dependencies: HttpTransportDependencies): HttpTransport { + const maxBodyBytes = dependencies.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + if (!Number.isInteger(maxBodyBytes) || maxBodyBytes < 1) { + throw new TypeError('maxBodyBytes must be a positive integer'); + } + + return { + async handle(request, principal) { + if (request.method !== 'POST') { + return jsonResponse(queryFailure('INVALID_INPUT', 'Only POST is supported.'), 405); + } + + const route = parseRoute(new URL(request.url).pathname); + if (route === undefined) { + return jsonResponse(queryFailure('INVALID_INPUT', 'Unknown protocol route.'), 404); + } + + if (!isJsonContentType(request.headers.get('content-type'))) { + return jsonResponse( + queryFailure('INVALID_INPUT', 'Content-Type must be application/json.'), + 415, + ); + } + + const bodyResult = await readBoundedJson(request, maxBodyBytes); + if (!bodyResult.ok) { + const status = bodyResult.oversized ? 413 : 400; + return jsonResponse(queryFailure('INVALID_INPUT', bodyResult.message), status); + } + + if (route.kind === 'command') { + const parsed = parseApplicationCommand( + route.name as ApplicationCommandName, + bodyResult.value, + ); + if (!parsed.ok) { + return jsonResponse(commandFailureFromInput(bodyResult.value, parsed.message), 400); + } + if ( + !(await isAuthorized(dependencies.authorizer, principal, claimForCommand(parsed.value))) + ) { + return jsonResponse( + commandFailure(parsed.value, 'ACTOR_NOT_AUTHORIZED', 'Principal is not authorized.'), + 403, + ); + } + try { + const response = await dependencies.dispatcher.dispatchCommand(parsed.value); + return jsonResponse(response, statusForResponse(response)); + } catch { + return jsonResponse( + commandFailure(parsed.value, 'INTERNAL_ERROR', 'Application dispatch failed.'), + 500, + ); + } + } + + const parsed = parseApplicationQuery(route.name as ApplicationQueryName, bodyResult.value); + if (!parsed.ok) { + return jsonResponse(queryFailureFromInput(bodyResult.value, parsed.message), 400); + } + if (!(await isAuthorized(dependencies.authorizer, principal, claimForQuery(parsed.value)))) { + return jsonResponse( + queryFailure( + 'ACTOR_NOT_AUTHORIZED', + 'Principal is not authorized.', + parsed.value.correlationId, + ), + 403, + ); + } + try { + const response = await dependencies.dispatcher.dispatchQuery(parsed.value); + return jsonResponse(response, statusForResponse(response)); + } catch { + return jsonResponse( + queryFailure( + 'INTERNAL_ERROR', + 'Application dispatch failed.', + parsed.value.correlationId, + ), + 500, + ); + } + }, + }; +} + +function parseRoute(pathname: string): Route | undefined { + const parts = pathname.split('/').filter((part) => part.length > 0); + if (parts.length !== 3 || parts[0] !== 'v0.1') return undefined; + const name = parts[2]; + if (name === undefined) return undefined; + if (parts[1] === 'commands' && isApplicationCommandName(name)) { + return { kind: 'command', name }; + } + if (parts[1] === 'queries' && isApplicationQueryName(name)) { + return { kind: 'query', name }; + } + return undefined; +} + +async function readBoundedJson( + request: Request, + maxBodyBytes: number, +): Promise<{ ok: true; value: unknown } | { ok: false; message: string; oversized: boolean }> { + const contentLength = request.headers.get('content-length'); + if (contentLength !== null) { + const declared = Number(contentLength); + if (Number.isFinite(declared) && declared > maxBodyBytes) { + return { ok: false, message: 'Request body exceeds the configured limit.', oversized: true }; + } + } + + const reader = request.body?.getReader(); + if (reader === undefined) + return { ok: false, message: 'Request body is required.', oversized: false }; + const decoder = new TextDecoder('utf-8', { fatal: true }); + let bytes = 0; + let text = ''; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > maxBodyBytes) { + await reader.cancel(); + return { + ok: false, + message: 'Request body exceeds the configured limit.', + oversized: true, + }; + } + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + } catch { + return { ok: false, message: 'Request body is not valid UTF-8 JSON.', oversized: false }; + } + + try { + return { ok: true, value: JSON.parse(text) as unknown }; + } catch { + return { ok: false, message: 'Request body is malformed JSON.', oversized: false }; + } +} + +function isJsonContentType(value: string | null): boolean { + if (value === null) return false; + return value.split(';', 1)[0]?.trim().toLowerCase() === 'application/json'; +} + +function claimForCommand(command: ApplicationCommand): PrincipalClaim { + return { + workspaceId: command.workspaceId, + actor: command.actor, + ...(hasSessionId(command) ? { sessionId: command.sessionId } : {}), + operation: { kind: 'command', name: command.command }, + }; +} + +function claimForQuery(query: ApplicationQuery): PrincipalClaim { + return { + workspaceId: query.workspaceId, + actor: query.actor, + ...(hasSessionId(query) ? { sessionId: query.sessionId } : {}), + operation: { kind: 'query', name: query.query }, + }; +} + +function hasSessionId( + value: ApplicationCommand | ApplicationQuery, +): value is (ApplicationCommand | ApplicationQuery) & { sessionId: string } { + return 'sessionId' in value && typeof value.sessionId === 'string'; +} + +async function isAuthorized( + authorizer: PrincipalAuthorizer, + principal: AuthenticatedPrincipal, + claim: PrincipalClaim, +): Promise { + try { + return (await authorizer.authorize(principal, claim)) === true; + } catch { + return false; + } +} + +function commandFailure( + command: ApplicationCommand, + code: ApplicationErrorCode, + message: string, +): CommandFailure { + return { + protocolVersion: '0.1', + commandId: command.commandId, + ...(command.correlationId === undefined ? {} : { correlationId: command.correlationId }), + replayed: false, + error: { code, message, retryable: false }, + }; +} + +function commandFailureFromInput(input: unknown, message: string): CommandFailure { + const record = isRecord(input) ? input : {}; + return { + protocolVersion: '0.1', + ...(typeof record.commandId === 'string' ? { commandId: record.commandId } : {}), + ...(typeof record.correlationId === 'string' ? { correlationId: record.correlationId } : {}), + replayed: false, + error: { code: 'INVALID_INPUT', message, retryable: false }, + }; +} + +function queryFailure( + code: ApplicationErrorCode, + message: string, + correlationId?: string, +): QueryFailure { + return { + protocolVersion: '0.1', + ...(correlationId === undefined ? {} : { correlationId }), + error: { code, message, retryable: false }, + }; +} + +function queryFailureFromInput(input: unknown, message: string): QueryFailure { + const record = isRecord(input) ? input : {}; + return queryFailure( + 'INVALID_INPUT', + message, + typeof record.correlationId === 'string' ? record.correlationId : undefined, + ); +} + +function statusForResponse(response: CommandResponse | QueryResponse): number { + if (!('error' in response)) return 200; + return statusForErrorCode(response.error.code); +} + +function statusForErrorCode(code: ApplicationErrorCode): number { + switch (code) { + case 'INVALID_INPUT': + return 400; + case 'NOT_FOUND': + return 404; + case 'ACTOR_NOT_AUTHORIZED': + case 'PERMISSION_DENIED': + return 403; + case 'POLICY_UNAVAILABLE': + return 503; + case 'UNSUPPORTED_OPERATION': + return 501; + case 'INTERNAL_ERROR': + return 500; + case 'CONFLICT': + case 'REVISION_MISMATCH': + case 'LEASE_NOT_ACTIVE': + case 'LEASE_EXPIRED': + case 'STALE_FENCING_TOKEN': + case 'INVALID_STATE_TRANSITION': + case 'IDEMPOTENCY_CONFLICT': + case 'SESSION_NOT_ACTIVE': + case 'CAPABILITY_MISMATCH': + case 'DEPENDENCY_UNSATISFIED': + case 'HUMAN_DECISION_REQUIRED': + return 409; + } +} + +function jsonResponse(body: CommandResponse | QueryResponse, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/transports/mcp/adapter.ts b/src/transports/mcp/adapter.ts new file mode 100644 index 00000000..425df336 --- /dev/null +++ b/src/transports/mcp/adapter.ts @@ -0,0 +1,352 @@ +import type { + ApplicationDispatcher, + AuthenticatedPrincipal, + PrincipalAuthorizer, + PrincipalClaim, +} from '../../application/ports.ts'; +import { + type ApplicationCommand, + type ApplicationCommandName, + type ApplicationErrorCode, + type ApplicationQuery, + type ApplicationQueryName, + type CommandFailure, + type CommandResponse, + type QueryFailure, + type QueryResponse, +} from '../../application/protocol.ts'; +import { + COMMAND_SHAPES, + parseApplicationCommand, + parseApplicationQuery, + QUERY_SHAPES, +} from '../../application/validation.ts'; + +export interface McpTransportDependencies { + dispatcher: ApplicationDispatcher; + authorizer: PrincipalAuthorizer; +} + +export interface McpToolDefinition { + name: string; + description: string; + inputSchema: McpInputSchema; +} + +export interface McpInputSchema { + type: 'object'; + additionalProperties: false; + required: readonly string[]; + properties: Readonly>; +} + +export interface McpTransport { + listTools(): readonly McpToolDefinition[]; + callTool( + name: string, + args: unknown, + principal: AuthenticatedPrincipal, + ): Promise; +} + +interface CommandBinding { + tool: string; + kind: 'command'; + operation: ApplicationCommandName; + description: string; +} + +interface QueryBinding { + tool: string; + kind: 'query'; + operation: ApplicationQueryName; + description: string; +} + +type ToolBinding = CommandBinding | QueryBinding; + +const TOOL_BINDINGS: readonly ToolBinding[] = [ + command('mindrail_register_agent', 'RegisterAgent', 'Register a logical MindRail agent.'), + command('mindrail_start_session', 'StartSession', 'Start an agent execution session.'), + command('mindrail_heartbeat_session', 'HeartbeatSession', 'Refresh session liveness.'), + command('mindrail_end_session', 'EndSession', 'End an execution session.'), + command('mindrail_create_goal', 'CreateGoal', 'Create a MindRail goal.'), + command('mindrail_create_task', 'CreateTask', 'Create a task under a goal.'), + command('mindrail_claim_task', 'ClaimTask', 'Claim task execution authority.'), + command('mindrail_renew_lease', 'RenewLease', 'Renew current task lease authority.'), + command('mindrail_release_lease', 'ReleaseLease', 'Release current task lease authority.'), + command('mindrail_record_checkpoint', 'RecordCheckpoint', 'Record task progress or handoff.'), + command('mindrail_complete_task', 'CompleteTask', 'Complete a running task.'), + command('mindrail_fail_task', 'FailTask', 'Fail a running task.'), + command('mindrail_block_task', 'BlockTask', 'Block a running task.'), + command('mindrail_resume_task', 'ResumeTask', 'Resume an explicitly blocked task.'), + command('mindrail_retry_task', 'RetryTask', 'Retry a failed task explicitly.'), + command('mindrail_cancel_task', 'CancelTask', 'Cancel a nonterminal task.'), + command('mindrail_cancel_goal', 'CancelGoal', 'Cancel a goal and its nonterminal work.'), + command( + 'mindrail_request_permission', + 'RequestPermission', + 'Request scoped MindRail permission.', + ), + command( + 'mindrail_record_permission_decision', + 'RecordPermissionDecision', + 'Record an authenticated human permission decision.', + ), + query('mindrail_get_workspace', 'GetWorkspace', 'Read one workspace.'), + query('mindrail_get_goal', 'GetGoal', 'Read one goal.'), + query('mindrail_list_goals', 'ListGoals', 'List goals with bounded pagination.'), + query('mindrail_get_task', 'GetTask', 'Read one task.'), + query('mindrail_list_goal_tasks', 'ListGoalTasks', 'List goal tasks with bounded pagination.'), + query( + 'mindrail_list_claimable_tasks', + 'ListClaimableTasks', + 'List advisory claimable tasks with bounded pagination.', + ), + query( + 'mindrail_get_task_execution_view', + 'GetTaskExecutionView', + 'Read the bounded task execution view.', + ), + query( + 'mindrail_list_task_checkpoints', + 'ListTaskCheckpoints', + 'List task checkpoints with bounded pagination.', + ), + query('mindrail_get_agent', 'GetAgent', 'Read one agent.'), + query('mindrail_get_session', 'GetSession', 'Read one session.'), + query('mindrail_get_lease', 'GetLease', 'Read one lease.'), + query('mindrail_get_permission_request', 'GetPermissionRequest', 'Read one permission request.'), + query( + 'mindrail_list_pending_human_permissions', + 'ListPendingHumanPermissions', + 'List pending human permission requests with bounded pagination.', + ), + query( + 'mindrail_list_permission_decisions', + 'ListPermissionDecisions', + 'List permission decisions with bounded pagination.', + ), +]; + +export function createMcpTransport(dependencies: McpTransportDependencies): McpTransport { + const definitions = TOOL_BINDINGS.map(toToolDefinition); + const bindings = new Map(TOOL_BINDINGS.map((binding) => [binding.tool, binding])); + + return { + listTools() { + return definitions; + }, + + async callTool(name, args, principal) { + const binding = bindings.get(name); + if (binding === undefined) { + return queryFailure('INVALID_INPUT', 'Unknown MindRail MCP tool.'); + } + + if (binding.kind === 'command') { + const parsed = parseApplicationCommand(binding.operation, args); + if (!parsed.ok) return commandFailureFromInput(args, parsed.message); + if ( + !(await isAuthorized(dependencies.authorizer, principal, claimForCommand(parsed.value))) + ) { + return commandFailure( + parsed.value, + 'ACTOR_NOT_AUTHORIZED', + 'Principal is not authorized.', + ); + } + try { + return await dependencies.dispatcher.dispatchCommand(parsed.value); + } catch { + return commandFailure(parsed.value, 'INTERNAL_ERROR', 'Application dispatch failed.'); + } + } + + const parsed = parseApplicationQuery(binding.operation, args); + if (!parsed.ok) return queryFailureFromInput(args, parsed.message); + if (!(await isAuthorized(dependencies.authorizer, principal, claimForQuery(parsed.value)))) { + return queryFailure( + 'ACTOR_NOT_AUTHORIZED', + 'Principal is not authorized.', + parsed.value.correlationId, + ); + } + try { + return await dependencies.dispatcher.dispatchQuery(parsed.value); + } catch { + return queryFailure( + 'INTERNAL_ERROR', + 'Application dispatch failed.', + parsed.value.correlationId, + ); + } + }, + }; +} + +function command( + tool: string, + operation: ApplicationCommandName, + description: string, +): CommandBinding { + return { tool, kind: 'command', operation, description }; +} + +function query(tool: string, operation: ApplicationQueryName, description: string): QueryBinding { + return { tool, kind: 'query', operation, description }; +} + +function toToolDefinition(binding: ToolBinding): McpToolDefinition { + const commonRequired = + binding.kind === 'command' + ? ['protocolVersion', 'commandId', 'workspaceId', 'actor'] + : ['protocolVersion', 'workspaceId', 'actor']; + const shape = + binding.kind === 'command' + ? COMMAND_SHAPES[binding.operation] + : QUERY_SHAPES[binding.operation]; + const propertyNames = [ + ...commonRequired, + 'correlationId', + ...(binding.kind === 'command' ? ['causationId'] : []), + ...shape.required, + ...(shape.optional ?? []), + ]; + + return { + name: binding.tool, + description: binding.description, + inputSchema: { + type: 'object', + additionalProperties: false, + required: [...commonRequired, ...shape.required], + properties: Object.fromEntries(propertyNames.map((name) => [name, schemaForField(name)])), + }, + }; +} + +function schemaForField(name: string): unknown { + if (name === 'protocolVersion') return { type: 'string', const: '0.1' }; + if (name === 'actor') { + return { + type: 'object', + additionalProperties: false, + required: ['type', 'id'], + properties: { + type: { type: 'string', enum: ['system', 'human', 'agent'] }, + id: { type: 'string', minLength: 1, maxLength: 128 }, + }, + }; + } + if ( + name.startsWith('expected') || + name === 'fencingToken' || + name === 'limit' || + name === 'progressPercent' + ) { + return { type: 'integer', minimum: name === 'progressPercent' ? 0 : 1 }; + } + if ( + name === 'capabilities' || + name === 'successCriteria' || + name === 'acceptanceCriteria' || + name === 'requiredCapabilities' || + name === 'dependencyTaskIds' + ) { + return { type: 'array', items: { type: 'string' } }; + } + if (name === 'evidence') return { type: 'array', items: { type: 'object' } }; + if (name === 'reason' || name === 'resource') return { type: ['object', 'string'] }; + if (name === 'outcome') return { type: 'string', enum: ['ALLOW', 'DENY'] }; + if (name === 'kind') return { type: 'string', enum: ['progress', 'handoff'] }; + return { type: 'string' }; +} + +function claimForCommand(command: ApplicationCommand): PrincipalClaim { + return { + workspaceId: command.workspaceId, + actor: command.actor, + ...(hasSessionId(command) ? { sessionId: command.sessionId } : {}), + operation: { kind: 'command', name: command.command }, + }; +} + +function claimForQuery(queryValue: ApplicationQuery): PrincipalClaim { + return { + workspaceId: queryValue.workspaceId, + actor: queryValue.actor, + ...(hasSessionId(queryValue) ? { sessionId: queryValue.sessionId } : {}), + operation: { kind: 'query', name: queryValue.query }, + }; +} + +function hasSessionId( + value: ApplicationCommand | ApplicationQuery, +): value is (ApplicationCommand | ApplicationQuery) & { sessionId: string } { + return 'sessionId' in value && typeof value.sessionId === 'string'; +} + +async function isAuthorized( + authorizer: PrincipalAuthorizer, + principal: AuthenticatedPrincipal, + claim: PrincipalClaim, +): Promise { + try { + return (await authorizer.authorize(principal, claim)) === true; + } catch { + return false; + } +} + +function commandFailure( + commandValue: ApplicationCommand, + code: ApplicationErrorCode, + message: string, +): CommandFailure { + return { + protocolVersion: '0.1', + commandId: commandValue.commandId, + ...(commandValue.correlationId === undefined + ? {} + : { correlationId: commandValue.correlationId }), + replayed: false, + error: { code, message, retryable: false }, + }; +} + +function commandFailureFromInput(input: unknown, message: string): CommandFailure { + const record = isRecord(input) ? input : {}; + return { + protocolVersion: '0.1', + ...(typeof record.commandId === 'string' ? { commandId: record.commandId } : {}), + ...(typeof record.correlationId === 'string' ? { correlationId: record.correlationId } : {}), + replayed: false, + error: { code: 'INVALID_INPUT', message, retryable: false }, + }; +} + +function queryFailure( + code: ApplicationErrorCode, + message: string, + correlationId?: string, +): QueryFailure { + return { + protocolVersion: '0.1', + ...(correlationId === undefined ? {} : { correlationId }), + error: { code, message, retryable: false }, + }; +} + +function queryFailureFromInput(input: unknown, message: string): QueryFailure { + const record = isRecord(input) ? input : {}; + return queryFailure( + 'INVALID_INPUT', + message, + typeof record.correlationId === 'string' ? record.correlationId : undefined, + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/test/transports/http-adapter.test.ts b/test/transports/http-adapter.test.ts new file mode 100644 index 00000000..4c5afe7b --- /dev/null +++ b/test/transports/http-adapter.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ApplicationDispatcher } from '../../src/application/ports.ts'; +import { createHttpTransport } from '../../src/transports/http/adapter.ts'; + +const principal = { subject: 'principal-1' }; + +function commandBody(overrides: Record = {}) { + return { + protocolVersion: '0.1', + commandId: 'cmd-1', + workspaceId: 'ws-1', + actor: { type: 'human', id: 'human-1' }, + correlationId: 'corr-1', + title: 'Transport goal', + objective: 'Prove HTTP is only a protocol mapping boundary.', + successCriteria: ['The dispatcher owns semantic execution.'], + ...overrides, + }; +} + +function jsonRequest(path: string, body: unknown, headers: HeadersInit = {}) { + return new Request(`https://mindrail.invalid${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); +} + +function createDependencies() { + const dispatchCommand = vi.fn(async () => ({ + protocolVersion: '0.1', + commandId: 'cmd-1', + correlationId: 'corr-1', + replayed: false, + result: { id: 'goal-1' }, + })); + const dispatchQuery = vi.fn(async () => ({ + protocolVersion: '0.1', + correlationId: 'corr-1', + result: { id: 'ws-1' }, + })); + const authorize = vi.fn(async () => true); + + return { + dispatcher: { dispatchCommand, dispatchQuery }, + authorizer: { authorize }, + dispatchCommand, + dispatchQuery, + authorize, + }; +} + +async function json(response: Response): Promise> { + return (await response.json()) as Record; +} + +describe('HTTP v0.1 transport adapter', () => { + it('fails closed principal binding before application dispatch', async () => { + const deps = createDependencies(); + deps.authorize.mockResolvedValue(false); + const transport = createHttpTransport(deps); + const request = jsonRequest('/v0.1/commands/CreateGoal', commandBody()); + + const response = await transport.handle(request, principal); + const body = await json(response); + + expect(response.status).toBe(403); + expect(body.error).toEqual(expect.objectContaining({ code: 'ACTOR_NOT_AUTHORIZED' })); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + expect(deps.dispatchQuery).not.toHaveBeenCalled(); + }); + + it('rejects malformed JSON before application dispatch', async () => { + const deps = createDependencies(); + const transport = createHttpTransport(deps); + const request = new Request('https://mindrail.invalid/v0.1/commands/CreateGoal', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{not-json', + }); + + const response = await transport.handle(request, principal); + const body = await json(response); + + expect(response.status).toBe(400); + expect(body.error).toEqual(expect.objectContaining({ code: 'INVALID_INPUT' })); + expect(deps.authorize).not.toHaveBeenCalled(); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); + + it('rejects oversized bodies before application dispatch', async () => { + const deps = createDependencies(); + const transport = createHttpTransport({ ...deps, maxBodyBytes: 32 }); + const request = jsonRequest('/v0.1/commands/CreateGoal', commandBody()); + + const response = await transport.handle(request, principal); + const body = await json(response); + + expect(response.status).toBe(413); + expect(body.error).toEqual(expect.objectContaining({ code: 'INVALID_INPUT' })); + expect(deps.authorize).not.toHaveBeenCalled(); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); + + it('rejects unknown routes and discriminator mismatches deterministically', async () => { + const deps = createDependencies(); + const transport = createHttpTransport(deps); + const unknownRequest = jsonRequest('/v0.1/commands/DeleteEverything', commandBody()); + + const unknown = await transport.handle(unknownRequest, principal); + const unknownBody = await json(unknown); + + expect(unknown.status).toBe(404); + expect(unknownBody.error).toEqual(expect.objectContaining({ code: 'INVALID_INPUT' })); + + const mismatchRequest = jsonRequest( + '/v0.1/commands/CreateGoal', + commandBody({ command: 'CancelGoal' }), + ); + const mismatch = await transport.handle(mismatchRequest, principal); + const mismatchBody = await json(mismatch); + + expect(mismatch.status).toBe(400); + expect(mismatchBody.error).toEqual(expect.objectContaining({ code: 'INVALID_INPUT' })); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); + + it.each([ + ['INVALID_INPUT', 400], + ['NOT_FOUND', 404], + ['CONFLICT', 409], + ['ACTOR_NOT_AUTHORIZED', 403], + ['HUMAN_DECISION_REQUIRED', 409], + ] as const)( + 'preserves canonical error code %s while mapping HTTP status %i', + async (code, status) => { + const deps = createDependencies(); + deps.dispatchCommand.mockResolvedValueOnce({ + protocolVersion: '0.1', + commandId: 'cmd-1', + correlationId: 'corr-1', + replayed: false, + error: { code, message: 'bounded protocol failure', retryable: false }, + }); + const transport = createHttpTransport(deps); + const request = jsonRequest('/v0.1/commands/CreateGoal', commandBody()); + + const response = await transport.handle(request, principal); + const body = await json(response); + + expect(response.status).toBe(status); + expect(body).toEqual({ + protocolVersion: '0.1', + commandId: 'cmd-1', + correlationId: 'corr-1', + replayed: false, + error: { code, message: 'bounded protocol failure', retryable: false }, + }); + }, + ); + + it('preserves success envelopes and tracing/idempotency fields', async () => { + const deps = createDependencies(); + const transport = createHttpTransport(deps); + const body = commandBody({ causationId: 'cause-1' }); + const request = jsonRequest('/v0.1/commands/CreateGoal', body); + + const response = await transport.handle(request, principal); + + expect(response.status).toBe(200); + expect(await json(response)).toEqual({ + protocolVersion: '0.1', + commandId: 'cmd-1', + correlationId: 'corr-1', + replayed: false, + result: { id: 'goal-1' }, + }); + expect(deps.dispatchCommand).toHaveBeenCalledTimes(1); + expect(deps.dispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'CreateGoal', + commandId: 'cmd-1', + correlationId: 'corr-1', + causationId: 'cause-1', + }), + ); + }); + + it('maps bounded queries through the same authorization seam', async () => { + const deps = createDependencies(); + const transport = createHttpTransport(deps); + const request = jsonRequest('/v0.1/queries/GetWorkspace', { + protocolVersion: '0.1', + workspaceId: 'ws-1', + actor: { type: 'human', id: 'human-1' }, + correlationId: 'corr-1', + }); + + const response = await transport.handle(request, principal); + + expect(response.status).toBe(200); + expect(await json(response)).toEqual({ + protocolVersion: '0.1', + correlationId: 'corr-1', + result: { id: 'ws-1' }, + }); + expect(deps.dispatchQuery).toHaveBeenCalledWith( + expect.objectContaining({ query: 'GetWorkspace', workspaceId: 'ws-1' }), + ); + }); + + it('never exposes an authorization exception or credential', async () => { + const deps = createDependencies(); + deps.authorize.mockRejectedValue(new Error('Bearer super-secret-credential')); + const transport = createHttpTransport(deps); + const request = jsonRequest('/v0.1/commands/CreateGoal', commandBody()); + + const response = await transport.handle(request, { + subject: 'principal-secret-do-not-echo', + }); + const text = await response.text(); + + expect(response.status).toBe(403); + expect(text).toContain('ACTOR_NOT_AUTHORIZED'); + expect(text).not.toContain('super-secret-credential'); + expect(text).not.toContain('principal-secret-do-not-echo'); + expect(text).not.toContain('Error:'); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/test/transports/mcp-adapter.test.ts b/test/transports/mcp-adapter.test.ts new file mode 100644 index 00000000..d655d3fd --- /dev/null +++ b/test/transports/mcp-adapter.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { ApplicationDispatcher } from '../../src/application/ports.ts'; +import { createMcpTransport } from '../../src/transports/mcp/adapter.ts'; + +const principal = { subject: 'principal-1' }; + +function createDependencies() { + const dispatchCommand = vi.fn(async () => ({ + protocolVersion: '0.1', + commandId: 'cmd-1', + correlationId: 'corr-1', + replayed: false, + result: { id: 'goal-1' }, + })); + const dispatchQuery = vi.fn(async () => ({ + protocolVersion: '0.1', + correlationId: 'corr-1', + result: { id: 'ws-1' }, + })); + const authorize = vi.fn(async () => true); + + return { + dispatcher: { dispatchCommand, dispatchQuery }, + authorizer: { authorize }, + dispatchCommand, + dispatchQuery, + authorize, + }; +} + +function createGoalArgs(overrides: Record = {}) { + return { + protocolVersion: '0.1', + commandId: 'cmd-1', + workspaceId: 'ws-1', + actor: { type: 'human', id: 'human-1' }, + correlationId: 'corr-1', + title: 'MCP goal', + objective: 'Keep MCP as a semantic adapter.', + successCriteria: ['Only explicit MindRail operations are exposed.'], + ...overrides, + }; +} + +describe('MCP v0.1 semantic adapter', () => { + it('exposes only explicit accepted MindRail command/query tools', () => { + const transport = createMcpTransport(createDependencies()); + const tools = transport.listTools(); + const names = tools.map((tool) => tool.name); + + expect(names).toEqual([ + 'mindrail_register_agent', + 'mindrail_start_session', + 'mindrail_heartbeat_session', + 'mindrail_end_session', + 'mindrail_create_goal', + 'mindrail_create_task', + 'mindrail_claim_task', + 'mindrail_renew_lease', + 'mindrail_release_lease', + 'mindrail_record_checkpoint', + 'mindrail_complete_task', + 'mindrail_fail_task', + 'mindrail_block_task', + 'mindrail_resume_task', + 'mindrail_retry_task', + 'mindrail_cancel_task', + 'mindrail_cancel_goal', + 'mindrail_request_permission', + 'mindrail_record_permission_decision', + 'mindrail_get_workspace', + 'mindrail_get_goal', + 'mindrail_list_goals', + 'mindrail_get_task', + 'mindrail_list_goal_tasks', + 'mindrail_list_claimable_tasks', + 'mindrail_get_task_execution_view', + 'mindrail_list_task_checkpoints', + 'mindrail_get_agent', + 'mindrail_get_session', + 'mindrail_get_lease', + 'mindrail_get_permission_request', + 'mindrail_list_pending_human_permissions', + 'mindrail_list_permission_decisions', + ]); + expect(names).not.toEqual( + expect.arrayContaining([ + 'execute_action', + 'update_entity', + 'patch_object', + 'shell', + 'filesystem', + 'browser', + ]), + ); + for (const tool of tools) { + const expected = expect.objectContaining({ additionalProperties: false }); + expect(tool.inputSchema).toEqual(expected); + } + }); + + it('rejects invalid MCP arguments before principal binding or dispatch', async () => { + const deps = createDependencies(); + const transport = createMcpTransport(deps); + const args = createGoalArgs({ + commandId: '', + unexpectedAuthority: 'allow', + }); + + const response = await transport.callTool('mindrail_create_goal', args, principal); + + expect(response).toEqual( + expect.objectContaining({ + protocolVersion: '0.1', + error: expect.objectContaining({ code: 'INVALID_INPUT' }), + }), + ); + expect(deps.authorize).not.toHaveBeenCalled(); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); + + it('fails closed principal binding before MCP dispatch', async () => { + const deps = createDependencies(); + deps.authorize.mockResolvedValue(false); + const transport = createMcpTransport(deps); + + const response = await transport.callTool('mindrail_create_goal', createGoalArgs(), principal); + + expect(response).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ code: 'ACTOR_NOT_AUTHORIZED' }), + }), + ); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); + + it('preserves command tracing/idempotency fields and delegates once', async () => { + const deps = createDependencies(); + const transport = createMcpTransport(deps); + const args = createGoalArgs({ causationId: 'cause-1' }); + + const response = await transport.callTool('mindrail_create_goal', args, principal); + + expect(response).toEqual({ + protocolVersion: '0.1', + commandId: 'cmd-1', + correlationId: 'corr-1', + replayed: false, + result: { id: 'goal-1' }, + }); + expect(deps.dispatchCommand).toHaveBeenCalledTimes(1); + expect(deps.dispatchCommand).toHaveBeenCalledWith( + expect.objectContaining({ + command: 'CreateGoal', + commandId: 'cmd-1', + correlationId: 'corr-1', + causationId: 'cause-1', + }), + ); + }); + + it('maps explicit read tools only to the query dispatcher', async () => { + const deps = createDependencies(); + const transport = createMcpTransport(deps); + const args = { + protocolVersion: '0.1', + workspaceId: 'ws-1', + actor: { type: 'human', id: 'human-1' }, + correlationId: 'corr-1', + }; + + const response = await transport.callTool('mindrail_get_workspace', args, principal); + + expect(response).toEqual({ + protocolVersion: '0.1', + correlationId: 'corr-1', + result: { id: 'ws-1' }, + }); + expect(deps.dispatchQuery).toHaveBeenCalledTimes(1); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); + + it('returns bounded unsupported results for accepted parallel operations', async () => { + const deps = createDependencies(); + deps.dispatchCommand.mockResolvedValueOnce({ + protocolVersion: '0.1', + commandId: 'cmd-heartbeat', + replayed: false, + error: { + code: 'UNSUPPORTED_OPERATION', + message: 'HeartbeatSession is not integrated in this runtime composition.', + retryable: false, + }, + }); + const transport = createMcpTransport(deps); + const args = { + protocolVersion: '0.1', + commandId: 'cmd-heartbeat', + workspaceId: 'ws-1', + actor: { type: 'agent', id: 'agent-1' }, + sessionId: 'session-1', + expectedSessionRevision: 1, + }; + + const response = await transport.callTool('mindrail_heartbeat_session', args, principal); + + expect(response).toEqual( + expect.objectContaining({ + error: expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + }), + ); + expect(deps.dispatchCommand).toHaveBeenCalledTimes(1); + }); + + it('does not leak principal or authorization exception details', async () => { + const deps = createDependencies(); + const error = new Error('Authorization: Bearer mcp-super-secret'); + deps.authorize.mockRejectedValue(error); + const transport = createMcpTransport(deps); + + const response = await transport.callTool('mindrail_create_goal', createGoalArgs(), { + subject: 'mcp-principal-secret', + }); + const serialized = JSON.stringify(response); + + expect(serialized).toContain('ACTOR_NOT_AUTHORIZED'); + expect(serialized).not.toContain('mcp-super-secret'); + expect(serialized).not.toContain('mcp-principal-secret'); + expect(serialized).not.toContain('Error:'); + expect(deps.dispatchCommand).not.toHaveBeenCalled(); + }); +});