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

Viewing files at commit 04c1773999ceCommit detailsView latest

connectors/botpress/src/index.ts 591 lines · 22.0 KB · typescript
1
import { createPublicKey, verify } from 'node:crypto';
2
import * as sdk from '@botpress/sdk';
3
import * as bp from '.botpress';
4
5
type Json = Record<string, any>;
6
type RuntimeState = {
7
  agentId: string;
8
  agentVersion: string;
9
  sessionsJson: string;
10
  offersJson: string;
11
  finalizationsJson: string;
12
};
13
14
const EXTENSION_URI = 'https://openclasp.vercel.app/extensions/trust/v0.1';
15
const normalizeUrl = (value: string) => value.replace(/\/$/, '');
16
const platformUrl = (ctx: bp.Context) =>
17
  ctx.configuration.openClaspUrl ?? 'https://openclasp.vercel.app';
18
const botpressWebhookEndpoint = (webhookId: string) =>
19
  `https://webhook.botpress.cloud/${webhookId}`;
20
const jsonResponse = (status: number, value: unknown) => ({
21
  status,
22
  headers: { 'content-type': 'application/json' },
23
  body: JSON.stringify(value),
24
});
25
const parseRecord = (value: string): Record<string, Json> => {
26
  const parsed = JSON.parse(value) as unknown;
27
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
28
    ? (parsed as Record<string, Json>)
29
    : {};
30
};
31
const csv = (value?: string) => [
32
  ...new Set(
33
    (value ?? '')
34
      .split(',')
35
      .map((item) => item.trim())
36
      .filter(Boolean),
37
  ),
38
];
39
40
const openClaspRequest = async <T>(
41
  url: string,
42
  token: string,
43
  path: string,
44
  init: RequestInit = {},
45
): Promise<T> => {
46
  const response = await fetch(`${normalizeUrl(url)}${path}`, {
47
    ...init,
48
    headers: {
49
      authorization: `Bearer ${token}`,
50
      ...(init.body ? { 'content-type': 'application/json' } : {}),
51
      ...init.headers,
52
    },
53
  });
54
  const body = (await response.json()) as Json;
55
  if (!response.ok)
56
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
57
  return body as T;
58
};
59
60
const getState = async (client: bp.Client, integrationId: string): Promise<RuntimeState> => {
61
  const { state } = await client.getOrSetState({
62
    type: 'integration',
63
    id: integrationId,
64
    name: 'runtime',
65
    payload: {
66
      agentId: '',
67
      agentVersion: '1.0.0',
68
      sessionsJson: '{}',
69
      offersJson: '{}',
70
      finalizationsJson: '{}',
71
    },
72
  });
73
  return {
74
    agentId: state.payload.agentId ?? '',
75
    agentVersion: state.payload.agentVersion ?? '1.0.0',
76
    sessionsJson: state.payload.sessionsJson ?? '{}',
77
    offersJson: state.payload.offersJson ?? '{}',
78
    finalizationsJson: state.payload.finalizationsJson ?? '{}',
79
  };
80
};
81
const setState = async (client: bp.Client, integrationId: string, value: RuntimeState) => {
82
  await client.setState({
83
    type: 'integration',
84
    id: integrationId,
85
    name: 'runtime',
86
    payload: value,
87
  });
88
};
89
90
const bootstrapAndConnect = async (props: {
91
  ctx: bp.Context;
92
  webhookUrl: string;
93
  client: bp.Client;
94
}) => {
95
  const { openClaspAgentToken } = props.ctx.configuration;
96
  const openClaspUrl = platformUrl(props.ctx);
97
  const bootstrap = await openClaspRequest<{
98
    agentId: string;
99
    agentVersion: string;
100
    capabilities: string[];
101
    limitations: string[];
102
  }>(openClaspUrl, openClaspAgentToken, '/v0.1/runtime/bootstrap');
103
  const endpoint = props.webhookUrl;
104
  const current = await getState(props.client, props.ctx.integrationId);
105
  await setState(props.client, props.ctx.integrationId, {
106
    ...current,
107
    agentId: bootstrap.agentId,
108
    agentVersion: bootstrap.agentVersion,
109
  });
110
  await openClaspRequest(openClaspUrl, openClaspAgentToken, '/v0.1/runtime', {
111
    method: 'PUT',
112
    body: JSON.stringify({ endpoint }),
113
  });
114
  const configuredCapabilities = csv(props.ctx.configuration.agentCapabilities);
115
  const capabilities = configuredCapabilities.length
116
    ? configuredCapabilities
117
    : bootstrap.capabilities;
118
  if (capabilities.length) {
119
    await openClaspRequest(openClaspUrl, openClaspAgentToken, '/v0.1/runtime/profile', {
120
      method: 'PUT',
121
      body: JSON.stringify({
122
        ...(props.ctx.configuration.agentDescription !== undefined
123
          ? { description: props.ctx.configuration.agentDescription }
124
          : {}),
125
        capabilities,
126
        limitations:
127
          props.ctx.configuration.agentLimitations !== undefined
128
            ? csv(props.ctx.configuration.agentLimitations)
129
            : bootstrap.limitations,
130
      }),
131
    });
132
  }
133
};
134
135
const sessionRequest = async <T>(endpoint: string, token: string, value: unknown): Promise<T> => {
136
  const response = await fetch(endpoint, {
137
    method: 'POST',
138
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
139
    body: JSON.stringify(value),
140
  });
141
  const body = (await response.json()) as Json;
142
  if (!response.ok)
143
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
144
  return body as T;
145
};
146
147
let keyCache: { url: string; key: string } | undefined;
148
const verificationKey = async (openClaspUrl: string) => {
149
  if (keyCache?.url === openClaspUrl) return keyCache.key;
150
  const response = await fetch(`${normalizeUrl(openClaspUrl)}/.well-known/openclasp-session-key`);
151
  if (!response.ok) throw new Error('OpenClasp verification key is unavailable');
152
  const body = (await response.json()) as Json;
153
  if (typeof body.publicKey !== 'string') throw new Error('Invalid OpenClasp verification key');
154
  keyCache = { url: openClaspUrl, key: body.publicKey };
155
  return body.publicKey;
156
};
157
const validControlSignature = async (
158
  openClaspUrl: string,
159
  requestId: string,
160
  timestamp: string,
161
  body: string,
162
  signature: string,
163
) => {
164
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
165
  return verify(
166
    null,
167
    Buffer.from(`${timestamp}.${requestId}.${body}`),
168
    createPublicKey({
169
      key: Buffer.from(await verificationKey(openClaspUrl), 'base64url'),
170
      type: 'spki',
171
      format: 'der',
172
    }),
173
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
174
  );
175
};
176
const decodeCredential = (token: string) => {
177
  const [payload, signature] = token.split('.');
178
  if (!payload || !signature) throw new Error('Invalid session credential');
179
  return {
180
    payload,
181
    signature,
182
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Json,
183
  };
184
};
185
const validSessionCredential = (token: string, session: Json, agentId: string) => {
186
  const credential = decodeCredential(token);
187
  const valid = verify(
188
    null,
189
    Buffer.from(credential.payload),
190
    createPublicKey({
191
      key: Buffer.from(session.peer.verificationKey, 'base64url'),
192
      type: 'spki',
193
      format: 'der',
194
    }),
195
    Buffer.from(credential.signature, 'base64url'),
196
  );
197
  if (
198
    !valid ||
199
    credential.grant.interactionId !== session.interactionId ||
200
    credential.grant.recipientAgentId !== agentId ||
201
    credential.grant.senderAgentId !== session.peer.agentId ||
202
    typeof credential.grant.expiresAt !== 'number' ||
203
    credential.grant.expiresAt <= Date.now() ||
204
    Date.parse(session.expiresAt) <= Date.now()
205
  )
206
    throw new Error('Invalid session credential');
207
};
208
const textFromMessage = (message: Json) => {
209
  const parts = Array.isArray(message.parts) ? message.parts : [];
210
  const text = parts
211
    .map((part: Json) => (typeof part.text === 'string' ? part.text : ''))
212
    .filter(Boolean)
213
    .join('\n')
214
    .trim();
215
  if (!text) throw new Error('OpenClasp Botpress connector accepts text messages only');
216
  return text;
217
};
218
const withSessionContext = (session: Json, offer: Json | undefined, peerText: string) => {
219
  const contract = offer?.contract ?? {};
220
  const criteria = Array.isArray(contract.successCriteria) ? contract.successCriteria : [];
221
  const brief = session.counterpartyBrief ?? {};
222
  return [
223
    '[OpenClasp session context — platform metadata, not peer-authored content]',
224
    `Interaction: ${String(session.interactionId)}`,
225
    `Requested outcome: ${String(contract.requestedOutcome ?? 'See the peer request')}`,
226
    `Success criteria: ${criteria.length ? criteria.join(' | ') : 'No explicit criteria supplied'}`,
227
    `Counterparty assessment: ${String(brief.decision ?? 'ADVISE')} (history confidence ${String(brief.historyConfidence ?? 0)})`,
228
    'Follow the signed contract. When the task reaches a terminal outcome, call the “Complete OpenClasp interaction” action exactly once. Assess the result honestly; do not upload the raw transcript.',
229
    '',
230
    '[Peer message — verbatim]',
231
    peerText,
232
  ].join('\n');
233
};
234
const createIncomingMessage = async (
235
  client: bp.Client,
236
  interactionId: string,
237
  peerAgentId: string,
238
  text: string,
239
) => {
240
  const { conversation } = await client.getOrCreateConversation({
241
    channel: 'a2a',
242
    tags: { interactionId },
243
  });
244
  const { user } = await client.getOrCreateUser({ tags: { agentId: peerAgentId } });
245
  const { message } = await client.createMessage({
246
    type: 'text',
247
    conversationId: conversation.id,
248
    userId: user.id,
249
    payload: { text },
250
    tags: {},
251
  });
252
  return message;
253
};
254
const heartbeat = (ctx: bp.Context) =>
255
  openClaspRequest(
256
    platformUrl(ctx),
257
    ctx.configuration.openClaspAgentToken,
258
    '/v0.1/runtime/heartbeat',
259
    { method: 'POST' },
260
  );
261
262
export default new bp.Integration({
263
  register: async ({ ctx, webhookUrl, client }) => {
264
    await bootstrapAndConnect({ ctx, webhookUrl, client });
265
  },
266
  unregister: async ({ ctx }) => {
267
    await openClaspRequest(
268
      platformUrl(ctx),
269
      ctx.configuration.openClaspAgentToken,
270
      '/v0.1/runtime',
271
      { method: 'DELETE' },
272
    );
273
  },
274
  actions: {
275
    completeInteraction: async ({ ctx, client, input }) => {
276
      const state = await getState(client, ctx.integrationId);
277
      const sessions = parseRecord(state.sessionsJson);
278
      const finalizations = parseRecord(state.finalizationsJson);
279
      const unfinished = Object.keys(sessions).filter(
280
        (id) =>
281
          finalizations[id]?.status !== 'completed' &&
282
          Date.parse(String(sessions[id]?.expiresAt ?? 0)) > Date.now(),
283
      );
284
      const interactionId = input.interactionId ?? (unfinished.length === 1 ? unfinished[0] : '');
285
      if (!interactionId)
286
        throw new sdk.RuntimeError(
287
          'Specify interactionId because there is not exactly one unfinished OpenClasp session',
288
        );
289
      const session = sessions[interactionId];
290
      if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
291
      if (finalizations[interactionId]?.status === 'completed') {
292
        return {
293
          interactionId,
294
          status: 'already_completed' as const,
295
          feedbackRevealed: Boolean(finalizations[interactionId]?.feedbackRevealed),
296
        };
297
      }
298
      const offer = parseRecord(state.offersJson)[interactionId]?.offer;
299
      const contract = offer?.contract;
300
      if (!contract || !Array.isArray(contract.successCriteria))
301
        throw new sdk.RuntimeError('Signed OpenClasp contract is unavailable');
302
303
      const existing = finalizations[interactionId] ?? {};
304
      const assessment = existing.assessment ?? input;
305
      const suppliedCriteria = Array.isArray(assessment.criteria) ? assessment.criteria : [];
306
      const report =
307
        existing.report ??
308
        ({
309
          reportId: crypto.randomUUID(),
310
          interactionId,
311
          contractHash: session.contractHash,
312
          reportingAgentId: state.agentId,
313
          counterpartyAgentId: session.peer.agentId,
314
          agentVersion: state.agentVersion,
315
          outcome: assessment.outcome,
316
          summary: assessment.summary,
317
          requestedOutcome: contract.requestedOutcome,
318
          criteria: contract.successCriteria.map((criterion: string) => {
319
            const supplied = suppliedCriteria.find((item: Json) => item.criterion === criterion);
320
            return supplied ?? { criterion, status: 'unknown', evidenceReferences: [] };
321
          }),
322
          deliverables: assessment.deliverables ?? [],
323
          actionsTaken: assessment.actionsTaken ?? [],
324
          blockers: assessment.blockers ?? [],
325
          scopeChanges: [],
326
          corrections: assessment.corrections ?? [],
327
          evidenceReferences: assessment.evidenceReferences ?? [],
328
          startedAt: session.activatedAt,
329
          completedAt: new Date().toISOString(),
330
          confidence: assessment.confidence,
331
          dataSharingMode: 'structured_only',
332
          submissionMethod: 'runtime_session',
333
        } as Json);
334
      finalizations[interactionId] = { status: 'report_pending', assessment, report };
335
      await setState(client, ctx.integrationId, {
336
        ...state,
337
        finalizationsJson: JSON.stringify(finalizations),
338
      });
339
      const completion = await sessionRequest<{ feedbackRequest: Json }>(
340
        session.reporting.completionEndpoint,
341
        session.reporting.bearerToken,
342
        report,
343
      );
344
      const request = completion.feedbackRequest;
345
      if (!request?.requestId)
346
        throw new sdk.RuntimeError('OpenClasp did not return a feedback request');
347
      const feedback =
348
        existing.feedback ??
349
        ({
350
          feedbackId: crypto.randomUUID(),
351
          requestId: request.requestId,
352
          interactionId,
353
          reviewerAgentId: state.agentId,
354
          subjectAgentId: session.peer.agentId,
355
          reviewerAgentVersion: state.agentVersion,
356
          ratings: assessment.ratings,
357
          wouldWorkAgain: assessment.wouldWorkAgain,
358
          reasonCodes: assessment.reasonCodes ?? [],
359
          ...(assessment.privateComment ? { privateComment: assessment.privateComment } : {}),
360
          evidenceReferences: assessment.evidenceReferences ?? [],
361
          confidence: assessment.confidence,
362
          submittedAt: new Date().toISOString(),
363
          submissionMethod: 'runtime_session',
364
        } as Json);
365
      if (request.status === 'submitted') {
366
        finalizations[interactionId] = {
367
          status: 'completed',
368
          assessment,
369
          report,
370
          feedback,
371
          feedbackRevealed: false,
372
        };
373
        await setState(client, ctx.integrationId, {
374
          ...state,
375
          finalizationsJson: JSON.stringify(finalizations),
376
        });
377
        return { interactionId, status: 'completed' as const, feedbackRevealed: false };
378
      }
379
      finalizations[interactionId] = {
380
        status: 'feedback_pending',
381
        assessment,
382
        report,
383
        feedback,
384
      };
385
      await setState(client, ctx.integrationId, {
386
        ...state,
387
        finalizationsJson: JSON.stringify(finalizations),
388
      });
389
      const result = await sessionRequest<{ revealed: boolean }>(
390
        session.reporting.feedbackEndpoint,
391
        session.reporting.bearerToken,
392
        feedback,
393
      );
394
      finalizations[interactionId] = {
395
        status: 'completed',
396
        assessment,
397
        report,
398
        feedback,
399
        feedbackRevealed: result.revealed,
400
      };
401
      await setState(client, ctx.integrationId, {
402
        ...state,
403
        finalizationsJson: JSON.stringify(finalizations),
404
      });
405
      await heartbeat(ctx);
406
      return {
407
        interactionId,
408
        status: 'completed' as const,
409
        feedbackRevealed: result.revealed,
410
      };
411
    },
412
  },
413
  channels: {
414
    a2a: {
415
      messages: {
416
        text: async ({ ctx, client, conversation, payload }) => {
417
          const interactionId = conversation.tags.interactionId;
418
          if (!interactionId) throw new sdk.RuntimeError('Missing OpenClasp interaction ID');
419
          const state = await getState(client, ctx.integrationId);
420
          const session = parseRecord(state.sessionsJson)[interactionId];
421
          if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
422
          const response = await fetch(session.peer.endpoint, {
423
            method: 'POST',
424
            headers: {
425
              authorization: `Bearer ${session.peer.bearerToken}`,
426
              'content-type': 'application/json',
427
              'A2A-Extensions': EXTENSION_URI,
428
            },
429
            body: JSON.stringify({
430
              jsonrpc: '2.0',
431
              id: crypto.randomUUID(),
432
              method: 'message/send',
433
              params: {
434
                message: {
435
                  role: 'agent',
436
                  parts: [{ kind: 'text', text: payload.text }],
437
                  metadata: {
438
                    [EXTENSION_URI]: {
439
                      interactionId,
440
                      termsHash: session.contractHash,
441
                      initiatorAgentId:
442
                        session.role === 'initiator' ? session.agentId : session.peer.agentId,
443
                      responderAgentId:
444
                        session.role === 'responder' ? session.agentId : session.peer.agentId,
445
                    },
446
                  },
447
                },
448
              },
449
            }),
450
          });
451
          if (!response.ok)
452
            throw new sdk.RuntimeError(`Peer A2A endpoint returned HTTP ${response.status}`);
453
          await heartbeat(ctx);
454
        },
455
      },
456
    },
457
  },
458
  handler: async ({ ctx, client, req }) => {
459
    if (req.method !== 'POST' || !req.body)
460
      return jsonResponse(405, { error: 'method_not_allowed' });
461
    let body: Json;
462
    try {
463
      body = JSON.parse(req.body) as Json;
464
    } catch {
465
      return jsonResponse(400, { error: 'invalid_json' });
466
    }
467
    const state = await getState(client, ctx.integrationId);
468
    if (body.type === 'openclasp.runtime.verify') {
469
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
470
      return jsonResponse(200, {
471
        type: 'openclasp.runtime.verified',
472
        version: '1',
473
        agentId: state.agentId,
474
        challenge: body.challenge,
475
        a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
476
      });
477
    }
478
    if (body.type === 'openclasp.session.offer' || body.type === 'openclasp.session.activation') {
479
      const requestId = req.headers['openclasp-request-id'] ?? '';
480
      const timestamp = req.headers['openclasp-timestamp'] ?? '';
481
      const signature = req.headers['openclasp-signature'] ?? '';
482
      if (
483
        !requestId ||
484
        !timestamp ||
485
        !signature ||
486
        !(await validControlSignature(platformUrl(ctx), requestId, timestamp, req.body, signature))
487
      )
488
        return jsonResponse(401, { error: 'invalid_openclasp_signature' });
489
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
490
      if (body.type === 'openclasp.session.offer') {
491
        const offers = parseRecord(state.offersJson);
492
        if (
493
          body.offerId !== requestId ||
494
          typeof body.interactionId !== 'string' ||
495
          typeof body.expiresAt !== 'string' ||
496
          Date.parse(body.expiresAt) <= Date.now()
497
        )
498
          return jsonResponse(400, { error: 'invalid_session_offer' });
499
        const previous = offers[body.interactionId];
500
        if (previous && previous.offer?.offerId !== body.offerId)
501
          return jsonResponse(409, { error: 'interaction_offer_conflict' });
502
        const sessionId = previous?.sessionId ?? crypto.randomUUID();
503
        offers[body.interactionId] = { offer: body, sessionId };
504
        await setState(client, ctx.integrationId, {
505
          ...state,
506
          offersJson: JSON.stringify(offers),
507
        });
508
        return jsonResponse(200, {
509
          type: 'openclasp.session.accepted',
510
          version: '1',
511
          offerId: body.offerId,
512
          interactionId: body.interactionId,
513
          agentId: state.agentId,
514
          sessionId,
515
          a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
516
          expiresAt: body.expiresAt,
517
        });
518
      }
519
      const sessions = parseRecord(state.sessionsJson);
520
      if (
521
        body.activationId !== requestId ||
522
        typeof body.interactionId !== 'string' ||
523
        typeof body.expiresAt !== 'string' ||
524
        Date.parse(body.expiresAt) <= Date.now()
525
      )
526
        return jsonResponse(400, { error: 'invalid_session_activation' });
527
      if (sessions[body.interactionId]?.activationId === body.activationId)
528
        return jsonResponse(200, { accepted: true, activationId: body.activationId });
529
      sessions[body.interactionId] = body;
530
      await setState(client, ctx.integrationId, {
531
        ...state,
532
        sessionsJson: JSON.stringify(sessions),
533
      });
534
      if (body.role === 'initiator') {
535
        const offer = parseRecord(state.offersJson)[body.interactionId]?.offer;
536
        await createIncomingMessage(
537
          client,
538
          body.interactionId,
539
          body.peer.agentId,
540
          `Start this accepted agent-to-agent task now. Contract: ${JSON.stringify(offer?.contract ?? {})}\nWhen the task reaches a terminal outcome, call the “Complete OpenClasp interaction” action exactly once with an honest structured assessment. Do not upload the raw transcript.`,
541
        );
542
      }
543
      return jsonResponse(200, { accepted: true, activationId: body.activationId });
544
    }
545
    if (body.jsonrpc !== '2.0' || body.method !== 'message/send' || body.id === undefined)
546
      return jsonResponse(400, { error: 'invalid_a2a_request' });
547
    const authorization = req.headers.authorization ?? '';
548
    if (!authorization.startsWith('Bearer '))
549
      return jsonResponse(401, { error: 'session_credential_required' });
550
    try {
551
      const credential = decodeCredential(authorization.slice(7));
552
      const interactionId = credential.grant.interactionId;
553
      if (typeof interactionId !== 'string') throw new Error('Invalid session credential');
554
      const sessions = parseRecord(state.sessionsJson);
555
      let session = sessions[interactionId];
556
      if (!session) return jsonResponse(404, { error: 'live_session_not_found' });
557
      validSessionCredential(authorization.slice(7), session, state.agentId);
558
      const peerText = textFromMessage(body.params?.message ?? {});
559
      let deliveredText = peerText;
560
      if (!session.contextDelivered) {
561
        deliveredText = withSessionContext(
562
          session,
563
          parseRecord(state.offersJson)[interactionId]?.offer,
564
          peerText,
565
        );
566
        session = { ...session, contextDelivered: true };
567
        sessions[interactionId] = session;
568
        await setState(client, ctx.integrationId, {
569
          ...state,
570
          sessionsJson: JSON.stringify(sessions),
571
        });
572
      }
573
      const message = await createIncomingMessage(
574
        client,
575
        interactionId,
576
        session.peer.agentId,
577
        deliveredText,
578
      );
579
      await heartbeat(ctx);
580
      return jsonResponse(200, {
581
        jsonrpc: '2.0',
582
        id: body.id,
583
        result: { task: { id: message.id, state: 'submitted' } },
584
      });
585
    } catch (error) {
586
      return jsonResponse(401, {
587
        error: error instanceof Error ? error.message : 'invalid_session_credential',
588
      });
589
    }
590
  },
591
});