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

Viewing files at commit 5239911501a5Commit detailsView latest

connectors/botpress/src/index.ts 362 lines · 13.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 = { agentId: string; sessionsJson: string; offersJson: string };
7
8
const EXTENSION_URI = 'https://openclasp.vercel.app/extensions/trust/v0.1';
9
const normalizeUrl = (value: string) => value.replace(/\/$/, '');
10
const platformUrl = (ctx: bp.Context) =>
11
  ctx.configuration.openClaspUrl ?? 'https://openclasp.vercel.app';
12
const botpressWebhookEndpoint = (webhookId: string) =>
13
  `https://webhook.botpress.cloud/${webhookId}`;
14
const jsonResponse = (status: number, value: unknown) => ({
15
  status,
16
  headers: { 'content-type': 'application/json' },
17
  body: JSON.stringify(value),
18
});
19
const parseRecord = (value: string): Record<string, Json> => {
20
  const parsed = JSON.parse(value) as unknown;
21
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
22
    ? (parsed as Record<string, Json>)
23
    : {};
24
};
25
26
const openClaspRequest = async <T>(
27
  url: string,
28
  token: string,
29
  path: string,
30
  init: RequestInit = {},
31
): Promise<T> => {
32
  const response = await fetch(`${normalizeUrl(url)}${path}`, {
33
    ...init,
34
    headers: {
35
      authorization: `Bearer ${token}`,
36
      ...(init.body ? { 'content-type': 'application/json' } : {}),
37
      ...init.headers,
38
    },
39
  });
40
  const body = (await response.json()) as Json;
41
  if (!response.ok)
42
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
43
  return body as T;
44
};
45
46
const getState = async (client: bp.Client, integrationId: string): Promise<RuntimeState> => {
47
  const { state } = await client.getOrSetState({
48
    type: 'integration',
49
    id: integrationId,
50
    name: 'runtime',
51
    payload: { agentId: '', sessionsJson: '{}', offersJson: '{}' },
52
  });
53
  return state.payload;
54
};
55
const setState = async (client: bp.Client, integrationId: string, value: RuntimeState) => {
56
  await client.setState({
57
    type: 'integration',
58
    id: integrationId,
59
    name: 'runtime',
60
    payload: value,
61
  });
62
};
63
64
const bootstrapAndConnect = async (props: {
65
  ctx: bp.Context;
66
  webhookUrl: string;
67
  client: bp.Client;
68
}) => {
69
  const { openClaspAgentToken } = props.ctx.configuration;
70
  const openClaspUrl = platformUrl(props.ctx);
71
  const bootstrap = await openClaspRequest<{ agentId: string }>(
72
    openClaspUrl,
73
    openClaspAgentToken,
74
    '/v0.1/runtime/bootstrap',
75
  );
76
  const endpoint = props.webhookUrl;
77
  const current = await getState(props.client, props.ctx.integrationId);
78
  await setState(props.client, props.ctx.integrationId, {
79
    ...current,
80
    agentId: bootstrap.agentId,
81
  });
82
  await openClaspRequest(openClaspUrl, openClaspAgentToken, '/v0.1/runtime', {
83
    method: 'PUT',
84
    body: JSON.stringify({ endpoint }),
85
  });
86
};
87
88
let keyCache: { url: string; key: string } | undefined;
89
const verificationKey = async (openClaspUrl: string) => {
90
  if (keyCache?.url === openClaspUrl) return keyCache.key;
91
  const response = await fetch(`${normalizeUrl(openClaspUrl)}/.well-known/openclasp-session-key`);
92
  if (!response.ok) throw new Error('OpenClasp verification key is unavailable');
93
  const body = (await response.json()) as Json;
94
  if (typeof body.publicKey !== 'string') throw new Error('Invalid OpenClasp verification key');
95
  keyCache = { url: openClaspUrl, key: body.publicKey };
96
  return body.publicKey;
97
};
98
const validControlSignature = async (
99
  openClaspUrl: string,
100
  requestId: string,
101
  timestamp: string,
102
  body: string,
103
  signature: string,
104
) => {
105
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
106
  return verify(
107
    null,
108
    Buffer.from(`${timestamp}.${requestId}.${body}`),
109
    createPublicKey({
110
      key: Buffer.from(await verificationKey(openClaspUrl), 'base64url'),
111
      type: 'spki',
112
      format: 'der',
113
    }),
114
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
115
  );
116
};
117
const decodeCredential = (token: string) => {
118
  const [payload, signature] = token.split('.');
119
  if (!payload || !signature) throw new Error('Invalid session credential');
120
  return {
121
    payload,
122
    signature,
123
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Json,
124
  };
125
};
126
const validSessionCredential = (token: string, session: Json, agentId: string) => {
127
  const credential = decodeCredential(token);
128
  const valid = verify(
129
    null,
130
    Buffer.from(credential.payload),
131
    createPublicKey({
132
      key: Buffer.from(session.peer.verificationKey, 'base64url'),
133
      type: 'spki',
134
      format: 'der',
135
    }),
136
    Buffer.from(credential.signature, 'base64url'),
137
  );
138
  if (
139
    !valid ||
140
    credential.grant.interactionId !== session.interactionId ||
141
    credential.grant.recipientAgentId !== agentId ||
142
    credential.grant.senderAgentId !== session.peer.agentId ||
143
    typeof credential.grant.expiresAt !== 'number' ||
144
    credential.grant.expiresAt <= Date.now() ||
145
    Date.parse(session.expiresAt) <= Date.now()
146
  )
147
    throw new Error('Invalid session credential');
148
};
149
const textFromMessage = (message: Json) => {
150
  const parts = Array.isArray(message.parts) ? message.parts : [];
151
  const text = parts
152
    .map((part: Json) => (typeof part.text === 'string' ? part.text : ''))
153
    .filter(Boolean)
154
    .join('\n')
155
    .trim();
156
  if (!text) throw new Error('OpenClasp Botpress connector accepts text messages only');
157
  return text;
158
};
159
const createIncomingMessage = async (
160
  client: bp.Client,
161
  interactionId: string,
162
  peerAgentId: string,
163
  text: string,
164
) => {
165
  const { conversation } = await client.getOrCreateConversation({
166
    channel: 'a2a',
167
    tags: { interactionId },
168
  });
169
  const { user } = await client.getOrCreateUser({ tags: { agentId: peerAgentId } });
170
  const { message } = await client.createMessage({
171
    type: 'text',
172
    conversationId: conversation.id,
173
    userId: user.id,
174
    payload: { text },
175
    tags: {},
176
  });
177
  return message;
178
};
179
const heartbeat = (ctx: bp.Context) =>
180
  openClaspRequest(
181
    platformUrl(ctx),
182
    ctx.configuration.openClaspAgentToken,
183
    '/v0.1/runtime/heartbeat',
184
    { method: 'POST' },
185
  );
186
187
export default new bp.Integration({
188
  register: async ({ ctx, webhookUrl, client }) => {
189
    await bootstrapAndConnect({ ctx, webhookUrl, client });
190
  },
191
  unregister: async ({ ctx }) => {
192
    await openClaspRequest(
193
      platformUrl(ctx),
194
      ctx.configuration.openClaspAgentToken,
195
      '/v0.1/runtime',
196
      { method: 'DELETE' },
197
    );
198
  },
199
  actions: {},
200
  channels: {
201
    a2a: {
202
      messages: {
203
        text: async ({ ctx, client, conversation, payload }) => {
204
          const interactionId = conversation.tags.interactionId;
205
          if (!interactionId) throw new sdk.RuntimeError('Missing OpenClasp interaction ID');
206
          const state = await getState(client, ctx.integrationId);
207
          const session = parseRecord(state.sessionsJson)[interactionId];
208
          if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
209
          const response = await fetch(session.peer.endpoint, {
210
            method: 'POST',
211
            headers: {
212
              authorization: `Bearer ${session.peer.bearerToken}`,
213
              'content-type': 'application/json',
214
              'A2A-Extensions': EXTENSION_URI,
215
            },
216
            body: JSON.stringify({
217
              jsonrpc: '2.0',
218
              id: crypto.randomUUID(),
219
              method: 'message/send',
220
              params: {
221
                message: {
222
                  role: 'agent',
223
                  parts: [{ kind: 'text', text: payload.text }],
224
                  metadata: {
225
                    [EXTENSION_URI]: {
226
                      interactionId,
227
                      termsHash: session.contractHash,
228
                      initiatorAgentId:
229
                        session.role === 'initiator' ? session.agentId : session.peer.agentId,
230
                      responderAgentId:
231
                        session.role === 'responder' ? session.agentId : session.peer.agentId,
232
                    },
233
                  },
234
                },
235
              },
236
            }),
237
          });
238
          if (!response.ok)
239
            throw new sdk.RuntimeError(`Peer A2A endpoint returned HTTP ${response.status}`);
240
          await heartbeat(ctx);
241
        },
242
      },
243
    },
244
  },
245
  handler: async ({ ctx, client, req }) => {
246
    if (req.method !== 'POST' || !req.body)
247
      return jsonResponse(405, { error: 'method_not_allowed' });
248
    let body: Json;
249
    try {
250
      body = JSON.parse(req.body) as Json;
251
    } catch {
252
      return jsonResponse(400, { error: 'invalid_json' });
253
    }
254
    const state = await getState(client, ctx.integrationId);
255
    if (body.type === 'openclasp.runtime.verify') {
256
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
257
      return jsonResponse(200, {
258
        type: 'openclasp.runtime.verified',
259
        version: '1',
260
        agentId: state.agentId,
261
        challenge: body.challenge,
262
        a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
263
      });
264
    }
265
    if (body.type === 'openclasp.session.offer' || body.type === 'openclasp.session.activation') {
266
      const requestId = req.headers['openclasp-request-id'] ?? '';
267
      const timestamp = req.headers['openclasp-timestamp'] ?? '';
268
      const signature = req.headers['openclasp-signature'] ?? '';
269
      if (
270
        !requestId ||
271
        !timestamp ||
272
        !signature ||
273
        !(await validControlSignature(platformUrl(ctx), requestId, timestamp, req.body, signature))
274
      )
275
        return jsonResponse(401, { error: 'invalid_openclasp_signature' });
276
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
277
      if (body.type === 'openclasp.session.offer') {
278
        const offers = parseRecord(state.offersJson);
279
        if (
280
          body.offerId !== requestId ||
281
          typeof body.interactionId !== 'string' ||
282
          typeof body.expiresAt !== 'string' ||
283
          Date.parse(body.expiresAt) <= Date.now()
284
        )
285
          return jsonResponse(400, { error: 'invalid_session_offer' });
286
        const previous = offers[body.interactionId];
287
        if (previous && previous.offer?.offerId !== body.offerId)
288
          return jsonResponse(409, { error: 'interaction_offer_conflict' });
289
        const sessionId = previous?.sessionId ?? crypto.randomUUID();
290
        offers[body.interactionId] = { offer: body, sessionId };
291
        await setState(client, ctx.integrationId, {
292
          ...state,
293
          offersJson: JSON.stringify(offers),
294
        });
295
        return jsonResponse(200, {
296
          type: 'openclasp.session.accepted',
297
          version: '1',
298
          offerId: body.offerId,
299
          interactionId: body.interactionId,
300
          agentId: state.agentId,
301
          sessionId,
302
          a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
303
          expiresAt: body.expiresAt,
304
        });
305
      }
306
      const sessions = parseRecord(state.sessionsJson);
307
      if (
308
        body.activationId !== requestId ||
309
        typeof body.interactionId !== 'string' ||
310
        typeof body.expiresAt !== 'string' ||
311
        Date.parse(body.expiresAt) <= Date.now()
312
      )
313
        return jsonResponse(400, { error: 'invalid_session_activation' });
314
      if (sessions[body.interactionId]?.activationId === body.activationId)
315
        return jsonResponse(200, { accepted: true, activationId: body.activationId });
316
      sessions[body.interactionId] = body;
317
      await setState(client, ctx.integrationId, {
318
        ...state,
319
        sessionsJson: JSON.stringify(sessions),
320
      });
321
      if (body.role === 'initiator') {
322
        const offer = parseRecord(state.offersJson)[body.interactionId]?.offer;
323
        await createIncomingMessage(
324
          client,
325
          body.interactionId,
326
          body.peer.agentId,
327
          `Start this accepted agent-to-agent task now. Contract: ${JSON.stringify(offer?.contract ?? {})}`,
328
        );
329
      }
330
      return jsonResponse(200, { accepted: true, activationId: body.activationId });
331
    }
332
    if (body.jsonrpc !== '2.0' || body.method !== 'message/send' || body.id === undefined)
333
      return jsonResponse(400, { error: 'invalid_a2a_request' });
334
    const authorization = req.headers.authorization ?? '';
335
    if (!authorization.startsWith('Bearer '))
336
      return jsonResponse(401, { error: 'session_credential_required' });
337
    try {
338
      const credential = decodeCredential(authorization.slice(7));
339
      const interactionId = credential.grant.interactionId;
340
      if (typeof interactionId !== 'string') throw new Error('Invalid session credential');
341
      const session = parseRecord(state.sessionsJson)[interactionId];
342
      if (!session) return jsonResponse(404, { error: 'live_session_not_found' });
343
      validSessionCredential(authorization.slice(7), session, state.agentId);
344
      const message = await createIncomingMessage(
345
        client,
346
        interactionId,
347
        session.peer.agentId,
348
        textFromMessage(body.params?.message ?? {}),
349
      );
350
      await heartbeat(ctx);
351
      return jsonResponse(200, {
352
        jsonrpc: '2.0',
353
        id: body.id,
354
        result: { task: { id: message.id, state: 'submitted' } },
355
      });
356
    } catch (error) {
357
      return jsonResponse(401, {
358
        error: error instanceof Error ? error.message : 'invalid_session_credential',
359
      });
360
    }
361
  },
362
});