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

Viewing files at commit d596f9595c49Commit detailsView latest

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