The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit c2393f876b9dCommit detailsView latest

packages/persistence/src/onboarding.ts 437 lines · 15.3 KB · typescript
1
import { randomUUID } from 'node:crypto';
2
3
export type Project = {
4
  projectId: string;
5
  name: string;
6
  createdAt: string;
7
};
8
9
export type AgentProfile = {
10
  agentId: string;
11
  projectId: string;
12
  name: string;
13
  description: string;
14
  framework: string;
15
  agentVersion: string;
16
  modelProvider?: string;
17
  modelName?: string;
18
  nameProvenance?: 'operator_attested';
19
  profileProvenance?: 'self_declared' | 'operator_attested';
20
  agentMode?: 'persistent_runtime';
21
  a2aEndpoint?: string;
22
  transport?: 'direct_a2a' | 'openclasp_gateway';
23
  autoPublish: boolean;
24
  autoAcceptPolicy: 'off' | 'safe_matching';
25
  autoAcceptTaskCategories: string[];
26
  capabilities: string[];
27
  limitations: string[];
28
  identityMode: 'oauth_installation' | 'owner_managed' | 'connector_claim';
29
  status: 'active' | 'revoked';
30
  createdAt: string;
31
  updatedAt: string;
32
};
33
34
export type AgentInstallation = {
35
  installationId: string;
36
  clientId: string;
37
  agentId: string;
38
  projectId: string;
39
  connectedAt: string;
40
  updatedAt: string;
41
};
42
43
export type SetupRequest = {
44
  requestId: string;
45
  clientId: string;
46
  action: 'connect' | 'switch';
47
  status: 'pending' | 'approved' | 'rejected';
48
  agentName?: string;
49
  description?: string;
50
  projectName?: string;
51
  projectId?: string;
52
  existingAgentId?: string;
53
  framework: string;
54
  agentVersion: string;
55
  agentMode: 'persistent_runtime';
56
  a2aEndpoint?: string;
57
  autoPublish: boolean;
58
  autoAcceptPolicy: 'off' | 'safe_matching';
59
  autoAcceptTaskCategories: string[];
60
  capabilities: string[];
61
  limitations: string[];
62
  requestedAt: string;
63
  decidedAt?: string;
64
  installationId?: string;
65
};
66
67
export type OnboardingKind = 'project' | 'agent_profile' | 'installation' | 'setup_request';
68
69
export type OnboardingRow = {
70
  kind: string;
71
  recordId: string;
72
  payload: any;
73
};
74
75
export interface OnboardingStore {
76
  list(operatorId: string): Promise<OnboardingRow[]>;
77
  upsert(
78
    operatorId: string,
79
    kind: OnboardingKind,
80
    recordId: string,
81
    payload: unknown,
82
    metadata?: {
83
      provenance?: 'self_declared' | 'operator_attested' | 'cryptographically_verified';
84
      schemaName?: string;
85
      schemaVersion?: string;
86
    },
87
  ): Promise<void>;
88
}
89
90
export type OnboardingState = {
91
  projects: Project[];
92
  agentProfiles: AgentProfile[];
93
  installations: AgentInstallation[];
94
  setupRequests: SetupRequest[];
95
};
96
97
export async function getOnboardingState(
98
  store: OnboardingStore,
99
  operatorId: string,
100
): Promise<OnboardingState> {
101
  const rows = await store.list(operatorId);
102
  const values = <T>(kind: OnboardingKind) =>
103
    rows.filter((row) => row.kind === kind).map((row) => row.payload as T);
104
  return {
105
    projects: values<Project>('project'),
106
    agentProfiles: values<AgentProfile>('agent_profile').map((agent) => ({
107
      ...agent,
108
      agentMode: 'persistent_runtime',
109
      transport: agent.a2aEndpoint ? 'direct_a2a' : (agent.transport ?? 'direct_a2a'),
110
      description: agent.description ?? '',
111
      agentVersion: agent.agentVersion ?? '1.0.0',
112
      autoPublish: agent.autoPublish ?? false,
113
      autoAcceptPolicy: agent.autoAcceptPolicy ?? 'off',
114
      autoAcceptTaskCategories: agent.autoAcceptTaskCategories ?? [],
115
    })),
116
    installations: values<AgentInstallation>('installation'),
117
    setupRequests: values<SetupRequest>('setup_request'),
118
  };
119
}
120
121
export async function resolveInstallation(
122
  store: OnboardingStore,
123
  operatorId: string,
124
  clientId: string,
125
) {
126
  const state = await getOnboardingState(store, operatorId);
127
  const installation = state.installations.find((item) => item.clientId === clientId);
128
  if (!installation) return { status: 'unbound' as const };
129
  const agent = state.agentProfiles.find((item) => item.agentId === installation.agentId);
130
  const project = state.projects.find((item) => item.projectId === installation.projectId);
131
  if (!agent || !project) return { status: 'invalid_binding' as const, installation };
132
  return { status: 'connected' as const, installation, agent, project };
133
}
134
135
export async function createHostedProviderAgent(
136
  store: OnboardingStore,
137
  operatorId: string,
138
  input: {
139
    provider: 'botpress' | 'custom';
140
    agentName: string;
141
    projectName: string;
142
    description?: string | undefined;
143
    capabilities?: string[] | undefined;
144
    limitations?: string[] | undefined;
145
  },
146
) {
147
  const state = await getOnboardingState(store, operatorId);
148
  const projectName = input.projectName.trim();
149
  const agentName = input.agentName.trim();
150
  if (!projectName || !agentName) throw new Error('Agent and project names are required');
151
  const now = new Date().toISOString();
152
  let project = state.projects.find(
153
    (candidate) => candidate.name.trim().toLowerCase() === projectName.toLowerCase(),
154
  );
155
  if (!project) {
156
    project = { projectId: `project_${randomUUID()}`, name: projectName, createdAt: now };
157
    await store.upsert(operatorId, 'project', project.projectId, project);
158
  }
159
  const agent: AgentProfile = {
160
    agentId: `agent_${randomUUID()}`,
161
    projectId: project.projectId,
162
    name: agentName,
163
    description: input.description?.trim() ?? '',
164
    framework: input.provider === 'botpress' ? 'Botpress' : 'Custom runtime',
165
    agentVersion: '1.0.0',
166
    agentMode: 'persistent_runtime',
167
    transport: 'direct_a2a',
168
    autoPublish: false,
169
    autoAcceptPolicy: 'off',
170
    autoAcceptTaskCategories: [],
171
    capabilities: [...new Set(input.capabilities ?? [])],
172
    limitations: [...new Set(input.limitations ?? [])],
173
    identityMode: 'owner_managed',
174
    status: 'active',
175
    createdAt: now,
176
    updatedAt: now,
177
  };
178
  await store.upsert(operatorId, 'agent_profile', agent.agentId, agent);
179
  return { project, agent };
180
}
181
182
export async function createDashboardAgent(
183
  store: OnboardingStore,
184
  operatorId: string,
185
  input: {
186
    agentName: string;
187
    projectName: string;
188
    description: string;
189
    framework?: string | undefined;
190
    capabilities: string[];
191
    limitations?: string[] | undefined;
192
  },
193
) {
194
  const state = await getOnboardingState(store, operatorId);
195
  const projectName = input.projectName.trim();
196
  const agentName = input.agentName.trim();
197
  if (!projectName || !agentName) throw new Error('Agent and project names are required');
198
  const capabilities = [
199
    ...new Set(input.capabilities.map((value) => value.trim()).filter(Boolean)),
200
  ];
201
  if (!capabilities.length) throw new Error('At least one capability is required');
202
  const now = new Date().toISOString();
203
  let project = state.projects.find(
204
    (candidate) => candidate.name.trim().toLowerCase() === projectName.toLowerCase(),
205
  );
206
  if (!project) {
207
    project = { projectId: `project_${randomUUID()}`, name: projectName, createdAt: now };
208
    await store.upsert(operatorId, 'project', project.projectId, project);
209
  }
210
  const existing = state.agentProfiles.find(
211
    (candidate) =>
212
      candidate.status === 'active' &&
213
      candidate.projectId === project!.projectId &&
214
      candidate.name.trim().toLowerCase() === agentName.toLowerCase(),
215
  );
216
  if (existing) return { project, agent: existing };
217
  const agent: AgentProfile = {
218
    agentId: `agent_${randomUUID()}`,
219
    projectId: project.projectId,
220
    name: agentName,
221
    description: input.description.trim(),
222
    framework: input.framework?.trim() || 'Custom agent',
223
    agentVersion: '1.0.0',
224
    agentMode: 'persistent_runtime',
225
    transport: 'direct_a2a',
226
    autoPublish: false,
227
    autoAcceptPolicy: 'off',
228
    autoAcceptTaskCategories: [],
229
    capabilities,
230
    limitations: [
231
      ...new Set((input.limitations ?? []).map((value) => value.trim()).filter(Boolean)),
232
    ],
233
    identityMode: 'owner_managed',
234
    status: 'active',
235
    createdAt: now,
236
    updatedAt: now,
237
  };
238
  await store.upsert(operatorId, 'agent_profile', agent.agentId, agent);
239
  return { project, agent };
240
}
241
242
export async function requestAgentSetup(
243
  store: OnboardingStore,
244
  operatorId: string,
245
  input: {
246
    clientId: string;
247
    action?: 'connect' | 'switch' | undefined;
248
    agentName?: string | undefined;
249
    projectName?: string | undefined;
250
    projectId?: string | undefined;
251
    existingAgentId?: string | undefined;
252
    framework?: string | undefined;
253
    description?: string | undefined;
254
    agentVersion?: string | undefined;
255
    agentMode?: 'persistent_runtime' | undefined;
256
    a2aEndpoint?: string | undefined;
257
    autoAcceptPolicy?: 'off' | 'safe_matching' | undefined;
258
    autoAcceptTaskCategories?: string[] | undefined;
259
    capabilities?: string[] | undefined;
260
    limitations?: string[] | undefined;
261
  },
262
): Promise<SetupRequest> {
263
  const state = await getOnboardingState(store, operatorId);
264
  const pending = state.setupRequests.find(
265
    (item) => item.clientId === input.clientId && item.status === 'pending',
266
  );
267
  if (pending) return pending;
268
  if (
269
    !input.existingAgentId &&
270
    (!input.agentName?.trim() || (!input.projectId && !input.projectName?.trim()))
271
  )
272
    throw new Error('agentName and either projectName or projectId are required for a new agent');
273
  if (
274
    input.existingAgentId &&
275
    !state.agentProfiles.some((agent) => agent.agentId === input.existingAgentId)
276
  )
277
    throw new Error('Existing agent not found');
278
  if (input.projectId && !state.projects.some((project) => project.projectId === input.projectId))
279
    throw new Error('Project not found');
280
281
  const request: SetupRequest = {
282
    requestId: randomUUID(),
283
    clientId: input.clientId,
284
    action: input.action ?? (input.existingAgentId ? 'switch' : 'connect'),
285
    status: 'pending',
286
    ...(input.agentName?.trim() ? { agentName: input.agentName.trim() } : {}),
287
    ...(input.projectName?.trim() ? { projectName: input.projectName.trim() } : {}),
288
    ...(input.projectId ? { projectId: input.projectId } : {}),
289
    ...(input.existingAgentId ? { existingAgentId: input.existingAgentId } : {}),
290
    framework: input.framework?.trim() || 'unknown',
291
    description: input.description?.trim() || '',
292
    agentVersion: input.agentVersion?.trim() || '1.0.0',
293
    agentMode: 'persistent_runtime',
294
    ...(input.a2aEndpoint?.trim() ? { a2aEndpoint: input.a2aEndpoint.trim() } : {}),
295
    autoPublish: false,
296
    autoAcceptPolicy: input.autoAcceptPolicy ?? 'safe_matching',
297
    autoAcceptTaskCategories: [
298
      ...new Set(input.autoAcceptTaskCategories ?? input.capabilities ?? []),
299
    ],
300
    capabilities: [...new Set(input.capabilities ?? [])],
301
    limitations: [...new Set(input.limitations ?? [])],
302
    requestedAt: new Date().toISOString(),
303
  };
304
  await store.upsert(operatorId, 'setup_request', request.requestId, request);
305
  return request;
306
}
307
308
export async function approveAgentSetup(
309
  store: OnboardingStore,
310
  operatorId: string,
311
  requestId: string,
312
) {
313
  const state = await getOnboardingState(store, operatorId);
314
  const request = state.setupRequests.find((item) => item.requestId === requestId);
315
  if (!request) throw new Error('Setup request not found');
316
  if (request.status === 'rejected') throw new Error('Setup request was rejected');
317
  if (request.status === 'approved')
318
    return resolveInstallation(store, operatorId, request.clientId);
319
320
  const now = new Date().toISOString();
321
  let agent = request.existingAgentId
322
    ? state.agentProfiles.find((item) => item.agentId === request.existingAgentId)
323
    : undefined;
324
  let project = agent
325
    ? state.projects.find((item) => item.projectId === agent!.projectId)
326
    : request.projectId
327
      ? state.projects.find((item) => item.projectId === request.projectId)
328
      : undefined;
329
330
  if (!project) {
331
    project = {
332
      projectId: `project_${randomUUID()}`,
333
      name: request.projectName!,
334
      createdAt: now,
335
    };
336
    await store.upsert(operatorId, 'project', project.projectId, project);
337
  }
338
  if (!agent) {
339
    agent = {
340
      agentId: `agent_${randomUUID()}`,
341
      projectId: project.projectId,
342
      name: request.agentName!,
343
      description: request.description ?? '',
344
      framework: request.framework,
345
      agentVersion: request.agentVersion,
346
      agentMode: 'persistent_runtime',
347
      transport: 'direct_a2a',
348
      autoPublish: request.autoPublish ?? false,
349
      autoAcceptPolicy: request.autoAcceptPolicy ?? 'off',
350
      autoAcceptTaskCategories: request.autoAcceptTaskCategories ?? [],
351
      capabilities: request.capabilities,
352
      limitations: request.limitations,
353
      identityMode: 'oauth_installation',
354
      status: 'active',
355
      createdAt: now,
356
      updatedAt: now,
357
    };
358
    await store.upsert(operatorId, 'agent_profile', agent.agentId, agent);
359
  }
360
361
  const previous = state.installations.find((item) => item.clientId === request.clientId);
362
  const installation: AgentInstallation = {
363
    installationId: previous?.installationId ?? `install_${randomUUID()}`,
364
    clientId: request.clientId,
365
    agentId: agent.agentId,
366
    projectId: project.projectId,
367
    connectedAt: previous?.connectedAt ?? now,
368
    updatedAt: now,
369
  };
370
  await store.upsert(operatorId, 'installation', request.clientId, installation);
371
  await store.upsert(operatorId, 'setup_request', request.requestId, {
372
    ...request,
373
    status: 'approved',
374
    decidedAt: now,
375
    installationId: installation.installationId,
376
  } satisfies SetupRequest);
377
  return { status: 'connected' as const, installation, agent, project };
378
}
379
380
export async function rejectAgentSetup(
381
  store: OnboardingStore,
382
  operatorId: string,
383
  requestId: string,
384
) {
385
  const state = await getOnboardingState(store, operatorId);
386
  const request = state.setupRequests.find((item) => item.requestId === requestId);
387
  if (!request) throw new Error('Setup request not found');
388
  if (request.status !== 'pending') throw new Error('Setup request is no longer pending');
389
  const rejected: SetupRequest = {
390
    ...request,
391
    status: 'rejected',
392
    decidedAt: new Date().toISOString(),
393
  };
394
  await store.upsert(operatorId, 'setup_request', requestId, rejected);
395
  return rejected;
396
}
397
398
export async function updateAgentProfile(
399
  store: OnboardingStore,
400
  operatorId: string,
401
  clientId: string,
402
  patch: {
403
    name?: string | undefined;
404
    description?: string | undefined;
405
    framework?: string | undefined;
406
    agentVersion?: string | undefined;
407
    agentMode?: 'persistent_runtime' | undefined;
408
    a2aEndpoint?: string | undefined;
409
    autoPublish?: boolean | undefined;
410
    autoAcceptPolicy?: 'off' | 'safe_matching' | undefined;
411
    autoAcceptTaskCategories?: string[] | undefined;
412
    capabilities?: string[] | undefined;
413
    limitations?: string[] | undefined;
414
  },
415
) {
416
  const binding = await resolveInstallation(store, operatorId, clientId);
417
  if (binding.status !== 'connected') throw new Error('This MCP installation is not connected');
418
  const agent: AgentProfile = {
419
    ...binding.agent,
420
    transport: 'direct_a2a',
421
    agentMode: 'persistent_runtime',
422
    ...(patch.name?.trim() ? { name: patch.name.trim() } : {}),
423
    ...(patch.description !== undefined ? { description: patch.description.trim() } : {}),
424
    ...(patch.framework?.trim() ? { framework: patch.framework.trim() } : {}),
425
    ...(patch.agentVersion?.trim() ? { agentVersion: patch.agentVersion.trim() } : {}),
426
    ...(patch.autoPublish !== undefined ? { autoPublish: patch.autoPublish } : {}),
427
    ...(patch.autoAcceptPolicy ? { autoAcceptPolicy: patch.autoAcceptPolicy } : {}),
428
    ...(patch.autoAcceptTaskCategories
429
      ? { autoAcceptTaskCategories: [...new Set(patch.autoAcceptTaskCategories)] }
430
      : {}),
431
    ...(patch.capabilities ? { capabilities: [...new Set(patch.capabilities)] } : {}),
432
    ...(patch.limitations ? { limitations: [...new Set(patch.limitations)] } : {}),
433
    updatedAt: new Date().toISOString(),
434
  };
435
  await store.upsert(operatorId, 'agent_profile', agent.agentId, agent);
436
  return agent;
437
}