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

Viewing files at commit 81afd7789b27Commit detailsView latest

packages/sdk/src/index.ts 592 lines · 20.1 KB · typescript
1
import {
2
  DEFAULT_EXTENSION_URI,
3
  canonicalHash,
4
  signObject,
5
  type InteractionEvent,
6
  type KeyPair,
7
  type TrustEnvelope,
8
  type FederatedInteraction,
9
  LiveSessionActivationSchema,
10
  LiveSessionEventSchema,
11
  LiveSessionOfferSchema,
12
  type LiveSessionActivation,
13
  type LiveSessionEvent,
14
  type LiveSessionOffer,
15
  type PublicAgentCard,
16
  type HostedThread,
17
  type HostedMessage,
18
} from '../../protocol/src/index.js';
19
import { createPublicKey, verify } from 'node:crypto';
20
export { createIdentity } from '../../core/src/index.js';
21
export * from '../../protocol/src/index.js';
22
23
export class OpenClaspClient {
24
  constructor(
25
    readonly baseUrl = 'http://localhost:3100/v0.1',
26
    readonly accessToken?: string,
27
  ) {}
28
  private async request<T>(path: string, init?: RequestInit): Promise<T> {
29
    const response = await fetch(`${this.baseUrl}${path}`, {
30
      ...init,
31
      headers: {
32
        ...(init?.body !== undefined && init.body !== null
33
          ? { 'content-type': 'application/json' }
34
          : {}),
35
        ...(this.accessToken ? { authorization: `Bearer ${this.accessToken}` } : {}),
36
        ...init?.headers,
37
      },
38
    });
39
    const body = (await response.json()) as any;
40
    if (!response.ok) throw new Error(body.error ?? `OpenClasp request failed: ${response.status}`);
41
    return body as T;
42
  }
43
  registerAgent(identity: unknown) {
44
    return this.request('/agents', { method: 'POST', body: JSON.stringify(identity) });
45
  }
46
  getProfile(agentId: string, version: string, taskCategory: string) {
47
    return this.request(
48
      `/profiles/${encodeURIComponent(agentId)}?version=${encodeURIComponent(version)}&taskCategory=${encodeURIComponent(taskCategory)}`,
49
    );
50
  }
51
  saveContract(contract: unknown) {
52
    return this.request('/interactions/contracts', {
53
      method: 'POST',
54
      body: JSON.stringify(contract),
55
    });
56
  }
57
  assess(input: unknown) {
58
    return this.request('/risk/assess', { method: 'POST', body: JSON.stringify(input) });
59
  }
60
  recordEvent(event: unknown) {
61
    return this.request('/events', { method: 'POST', body: JSON.stringify(event) });
62
  }
63
  checkClaim(claim: string) {
64
    return this.request('/claims/check', { method: 'POST', body: JSON.stringify({ claim }) });
65
  }
66
  submitReceipt(receipt: unknown) {
67
    return this.request('/receipts', { method: 'POST', body: JSON.stringify(receipt) });
68
  }
69
  verifyReceipt(receipt: unknown) {
70
    return this.request('/receipts/verify', { method: 'POST', body: JSON.stringify(receipt) });
71
  }
72
  submitFeedback(feedback: unknown) {
73
    return this.request('/feedback', { method: 'POST', body: JSON.stringify(feedback) });
74
  }
75
  setContributionConsent(agentId: string, enabled: boolean) {
76
    return this.request('/contributions/consent', {
77
      method: 'POST',
78
      body: JSON.stringify({ agentId, enabled }),
79
    });
80
  }
81
  getPublicAgentCard(agentId: string): Promise<PublicAgentCard> {
82
    const root = this.baseUrl.replace(/\/v0\.1\/?$/, '');
83
    return fetch(`${root}/agents/${encodeURIComponent(agentId)}/card.json`).then(
84
      async (response) => {
85
        const body = (await response.json()) as any;
86
        if (!response.ok)
87
          throw new Error(body.error ?? `OpenClasp request failed: ${response.status}`);
88
        return body as PublicAgentCard;
89
      },
90
    );
91
  }
92
  createFederatedInteraction(value: FederatedInteraction) {
93
    return this.request<FederatedInteraction>('/federated-interactions', {
94
      method: 'POST',
95
      body: JSON.stringify(value),
96
    });
97
  }
98
  listFederatedInteractions() {
99
    return this.request<FederatedInteraction[]>('/federated-interactions');
100
  }
101
  getFederatedInteraction(interactionId: string) {
102
    return this.request<FederatedInteraction>(
103
      `/federated-interactions/${encodeURIComponent(interactionId)}`,
104
    );
105
  }
106
  getLiveSession(interactionId: string, agentId: string): Promise<LiveSessionActivation> {
107
    return this.request(
108
      `/federated-interactions/${encodeURIComponent(interactionId)}/session?agentId=${encodeURIComponent(agentId)}`,
109
    );
110
  }
111
  respondToFederatedInteraction(
112
    interactionId: string,
113
    agentId: string,
114
    decision: 'accept' | 'reject',
115
  ) {
116
    return this.request<FederatedInteraction>(
117
      `/federated-interactions/${encodeURIComponent(interactionId)}/respond`,
118
      { method: 'POST', body: JSON.stringify({ agentId, decision }) },
119
    );
120
  }
121
  listHostedThreads(agentId: string): Promise<HostedThread[]> {
122
    return this.request(`/agents/${encodeURIComponent(agentId)}/threads`);
123
  }
124
  getHostedThread(
125
    agentId: string,
126
    threadId: string,
127
  ): Promise<{
128
    thread: HostedThread;
129
    messages: HostedMessage[];
130
    insights: unknown[];
131
  }> {
132
    return this.request(
133
      `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`,
134
    );
135
  }
136
  sendTemporaryMessage(agentId: string, interactionId: string, content: string) {
137
    return this.request(`/agents/${encodeURIComponent(agentId)}/messages`, {
138
      method: 'POST',
139
      body: JSON.stringify({ interactionId, content }),
140
    });
141
  }
142
  markHostedThreadRead(agentId: string, threadId: string) {
143
    return this.request(
144
      `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}/read`,
145
      { method: 'POST' },
146
    );
147
  }
148
  closeHostedThread(agentId: string, threadId: string) {
149
    return this.request(
150
      `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}/close`,
151
      { method: 'POST' },
152
    );
153
  }
154
  getRuntimeBootstrap(): Promise<RuntimeBootstrap> {
155
    return this.request('/runtime/bootstrap');
156
  }
157
  connectRuntime(endpoint: string): Promise<RuntimeConnection> {
158
    return this.request('/runtime', {
159
      method: 'PUT',
160
      body: JSON.stringify({ endpoint }),
161
    });
162
  }
163
  disconnectRuntime(): Promise<{ agentId: string; status: 'disabled' }> {
164
    return this.request('/runtime', { method: 'DELETE' });
165
  }
166
  heartbeatRuntime(): Promise<{ status: 'online' | 'offline'; checkedAt: string }> {
167
    return this.request('/runtime/heartbeat', { method: 'POST' });
168
  }
169
}
170
171
export type RuntimeBootstrap = {
172
  agentId: string;
173
  openClaspUrl: string;
174
  runtimeRegistrationEndpoint: string;
175
  protocol: 'A2A/1.0';
176
  controlProtocol: 'OpenClasp/0.1';
177
};
178
179
export type RuntimeConnection = {
180
  agentId: string;
181
  endpoint: string;
182
  a2aEndpoint: string;
183
  status: 'verified';
184
  verifiedAt: string;
185
  verificationKey: string;
186
};
187
188
export function createSignedEvent(
189
  input: Omit<InteractionEvent, 'payloadHash' | 'signature'>,
190
  key: KeyPair,
191
): InteractionEvent {
192
  return signObject(
193
    { ...input, payloadHash: canonicalHash(input.payload) },
194
    key,
195
  ) as InteractionEvent;
196
}
197
198
export function createSignedEnvelope(
199
  input: Omit<TrustEnvelope, 'signature'>,
200
  key: KeyPair,
201
): TrustEnvelope {
202
  return signObject(input, key) as TrustEnvelope;
203
}
204
205
function validRuntimeSignature(
206
  publicKey: string,
207
  requestId: string,
208
  timestamp: string,
209
  body: string,
210
  signature: string,
211
) {
212
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
213
  return verify(
214
    null,
215
    Buffer.from(`${timestamp}.${requestId}.${body}`),
216
    createPublicKey({ key: Buffer.from(publicKey, 'base64url'), type: 'spki', format: 'der' }),
217
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
218
  );
219
}
220
221
export function createOpenClaspRuntimeHandler(input: {
222
  agentId: string;
223
  a2aEndpoint: string;
224
  openClaspUrl?: string;
225
  openClaspVerificationKey?: string;
226
  onSessionOffer: (
227
    offer: LiveSessionOffer,
228
  ) =>
229
    Promise<{ accepted: boolean; sessionId?: string }> | { accepted: boolean; sessionId?: string };
230
  onSessionActivated: (session: LiveSessionActivation) => Promise<void> | void;
231
  loadSession: (interactionId: string) => Promise<LiveSessionActivation | undefined>;
232
  onMessage: (input: {
233
    session: LiveSessionActivation;
234
    requestId: string | number;
235
    message: unknown;
236
  }) => Promise<unknown> | unknown;
237
}) {
238
  let verificationKeyPromise: Promise<string> | undefined;
239
  const verificationKey = () => {
240
    if (input.openClaspVerificationKey) return Promise.resolve(input.openClaspVerificationKey);
241
    return (verificationKeyPromise ??= fetch(
242
      `${(input.openClaspUrl ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/.well-known/openclasp-session-key`,
243
    )
244
      .then(async (response) => {
245
        if (!response.ok) throw new Error(`OpenClasp key discovery failed: ${response.status}`);
246
        const value = (await response.json()) as { publicKey?: unknown };
247
        if (typeof value.publicKey !== 'string')
248
          throw new Error('Invalid OpenClasp verification key');
249
        return value.publicKey;
250
      })
251
      .catch((error) => {
252
        verificationKeyPromise = undefined;
253
        throw error;
254
      }));
255
  };
256
  return async (request: Request): Promise<Response> => {
257
    const body = await request.text();
258
    let parsed: Record<string, unknown>;
259
    try {
260
      parsed = JSON.parse(body) as Record<string, unknown>;
261
    } catch {
262
      return Response.json({ error: 'invalid_json' }, { status: 400 });
263
    }
264
    if (parsed.type === 'openclasp.runtime.verify')
265
      return Response.json({
266
        type: 'openclasp.runtime.verified',
267
        version: '1',
268
        agentId: parsed.agentId,
269
        challenge: parsed.challenge,
270
        a2aEndpoint: input.a2aEndpoint,
271
      });
272
    if (parsed.type !== 'openclasp.session.offer' && parsed.type !== 'openclasp.session.activation')
273
      return handleDirectMessage(request, parsed, input);
274
    const requestId = request.headers.get('openclasp-request-id') ?? '';
275
    const timestamp = request.headers.get('openclasp-timestamp') ?? '';
276
    const signature = request.headers.get('openclasp-signature') ?? '';
277
    let signatureValid = false;
278
    try {
279
      signatureValid = Boolean(
280
        requestId &&
281
        timestamp &&
282
        signature &&
283
        validRuntimeSignature(await verificationKey(), requestId, timestamp, body, signature),
284
      );
285
    } catch {
286
      return Response.json({ error: 'openclasp_key_unavailable' }, { status: 503 });
287
    }
288
    if (!signatureValid)
289
      return Response.json({ error: 'invalid_openclasp_signature' }, { status: 401 });
290
    if (parsed.type === 'openclasp.session.offer') {
291
      const result = LiveSessionOfferSchema.safeParse(parsed);
292
      if (
293
        !result.success ||
294
        result.data.offerId !== requestId ||
295
        result.data.agentId !== input.agentId
296
      )
297
        return Response.json({ error: 'invalid_session_offer' }, { status: 400 });
298
      const decision = await input.onSessionOffer(result.data);
299
      if (!decision.accepted) return Response.json({ error: 'session_declined' }, { status: 409 });
300
      if (!decision.sessionId)
301
        return Response.json({ error: 'durable_session_id_required' }, { status: 500 });
302
      return Response.json({
303
        type: 'openclasp.session.accepted',
304
        version: '1',
305
        offerId: result.data.offerId,
306
        interactionId: result.data.interactionId,
307
        agentId: input.agentId,
308
        sessionId: decision.sessionId,
309
        a2aEndpoint: input.a2aEndpoint,
310
        expiresAt: result.data.expiresAt,
311
      });
312
    }
313
    const activation = LiveSessionActivationSchema.safeParse(parsed);
314
    if (
315
      !activation.success ||
316
      activation.data.activationId !== requestId ||
317
      activation.data.agentId !== input.agentId
318
    )
319
      return Response.json({ error: 'invalid_session_activation' }, { status: 400 });
320
    await input.onSessionActivated(activation.data);
321
    return Response.json({ accepted: true, activationId: activation.data.activationId });
322
  };
323
}
324
325
type RuntimeHandlerInput = Parameters<typeof createOpenClaspRuntimeHandler>[0];
326
327
export interface RuntimeSessionStore {
328
  get(interactionId: string): Promise<LiveSessionActivation | undefined>;
329
  put(interactionId: string, session: LiveSessionActivation): Promise<void>;
330
}
331
332
export interface AgentRuntimeAdapter {
333
  readonly name: string;
334
  prepareSession(
335
    offer: LiveSessionOffer,
336
  ): Promise<{ accepted: boolean; sessionId?: string }> | { accepted: boolean; sessionId?: string };
337
  activateSession(session: LiveSessionActivation): Promise<void> | void;
338
  receiveMessage(input: {
339
    session: LiveSessionActivation;
340
    requestId: string | number;
341
    message: unknown;
342
  }): Promise<unknown> | unknown;
343
}
344
345
/** In-memory storage is useful for tests only. Production runtimes must use durable storage. */
346
export class MemoryRuntimeSessionStore implements RuntimeSessionStore {
347
  readonly sessions = new Map<string, LiveSessionActivation>();
348
349
  async get(interactionId: string) {
350
    return this.sessions.get(interactionId);
351
  }
352
353
  async put(interactionId: string, session: LiveSessionActivation) {
354
    this.sessions.set(interactionId, session);
355
  }
356
}
357
358
export function createAgentRuntimeConnector(input: {
359
  agentId: string;
360
  a2aEndpoint: string;
361
  adapter: AgentRuntimeAdapter;
362
  sessions: RuntimeSessionStore;
363
  openClaspUrl?: string;
364
  openClaspVerificationKey?: string;
365
}) {
366
  return createOpenClaspRuntimeHandler({
367
    agentId: input.agentId,
368
    a2aEndpoint: input.a2aEndpoint,
369
    ...(input.openClaspUrl ? { openClaspUrl: input.openClaspUrl } : {}),
370
    ...(input.openClaspVerificationKey
371
      ? { openClaspVerificationKey: input.openClaspVerificationKey }
372
      : {}),
373
    async onSessionOffer(offer) {
374
      const decision = await input.adapter.prepareSession(offer);
375
      return decision.accepted
376
        ? { accepted: true, sessionId: decision.sessionId ?? crypto.randomUUID() }
377
        : { accepted: false };
378
    },
379
    async onSessionActivated(session) {
380
      await input.sessions.put(session.interactionId, session);
381
      await input.adapter.activateSession(session);
382
    },
383
    loadSession: (interactionId) => input.sessions.get(interactionId),
384
    onMessage: (message) => input.adapter.receiveMessage(message),
385
  });
386
}
387
388
export class HttpAgentRuntimeAdapter implements AgentRuntimeAdapter {
389
  readonly name = 'http-webhook';
390
  private readonly baseUrl: string;
391
392
  constructor(
393
    baseUrl: string,
394
    private readonly options: {
395
      bearerToken?: string;
396
      timeoutMs?: number;
397
    } = {},
398
  ) {
399
    this.baseUrl = baseUrl.replace(/\/$/, '');
400
  }
401
402
  private async post<T>(path: string, body: unknown): Promise<T> {
403
    const response = await fetch(`${this.baseUrl}${path}`, {
404
      method: 'POST',
405
      headers: {
406
        'content-type': 'application/json',
407
        ...(this.options.bearerToken
408
          ? { authorization: `Bearer ${this.options.bearerToken}` }
409
          : {}),
410
      },
411
      body: JSON.stringify(body),
412
      signal: AbortSignal.timeout(this.options.timeoutMs ?? 30_000),
413
    });
414
    const text = await response.text();
415
    let value: unknown = {};
416
    if (text) {
417
      try {
418
        value = JSON.parse(text);
419
      } catch {
420
        throw new Error(`Agent adapter returned non-JSON from ${path}`);
421
      }
422
    }
423
    if (!response.ok) {
424
      const error = value as { error?: unknown };
425
      throw new Error(
426
        typeof error.error === 'string'
427
          ? error.error
428
          : `Agent adapter ${path} returned HTTP ${response.status}`,
429
      );
430
    }
431
    return value as T;
432
  }
433
434
  async prepareSession(offer: LiveSessionOffer) {
435
    const decision = await this.post<{ accepted?: unknown; sessionId?: unknown }>(
436
      '/openclasp/session-offer',
437
      offer,
438
    );
439
    return {
440
      accepted: decision.accepted === true,
441
      ...(typeof decision.sessionId === 'string' ? { sessionId: decision.sessionId } : {}),
442
    };
443
  }
444
445
  async activateSession(session: LiveSessionActivation) {
446
    await this.post('/openclasp/session-activated', session);
447
  }
448
449
  receiveMessage(input: {
450
    session: LiveSessionActivation;
451
    requestId: string | number;
452
    message: unknown;
453
  }) {
454
    return this.post('/openclasp/message', input);
455
  }
456
}
457
458
function decodeSessionCredential(token: string) {
459
  const [payload, signature] = token.split('.');
460
  if (!payload || !signature) throw new Error('Invalid live-session credential');
461
  return {
462
    payload,
463
    signature,
464
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
465
      interactionId?: unknown;
466
      senderAgentId?: unknown;
467
      recipientAgentId?: unknown;
468
      expiresAt?: unknown;
469
    },
470
  };
471
}
472
473
async function handleDirectMessage(
474
  request: Request,
475
  parsed: Record<string, unknown>,
476
  input: RuntimeHandlerInput,
477
) {
478
  const rpc = parsed as { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
479
  if (
480
    rpc.jsonrpc !== '2.0' ||
481
    (typeof rpc.id !== 'string' && typeof rpc.id !== 'number') ||
482
    rpc.method !== 'message/send'
483
  )
484
    return Response.json({ error: 'invalid_a2a_request' }, { status: 400 });
485
  const authorization = request.headers.get('authorization');
486
  if (!authorization?.startsWith('Bearer '))
487
    return Response.json({ error: 'session_credential_required' }, { status: 401 });
488
  let session: LiveSessionActivation;
489
  try {
490
    const credential = decodeSessionCredential(authorization.slice(7));
491
    if (typeof credential.grant.interactionId !== 'string')
492
      throw new Error('Invalid interaction credential');
493
    const loaded = await input.loadSession(credential.grant.interactionId);
494
    if (!loaded) return Response.json({ error: 'live_session_not_found' }, { status: 404 });
495
    session = loaded;
496
    const valid = verify(
497
      null,
498
      Buffer.from(credential.payload),
499
      createPublicKey({
500
        key: Buffer.from(session.peer.verificationKey, 'base64url'),
501
        type: 'spki',
502
        format: 'der',
503
      }),
504
      Buffer.from(credential.signature, 'base64url'),
505
    );
506
    if (
507
      !valid ||
508
      credential.grant.recipientAgentId !== input.agentId ||
509
      credential.grant.senderAgentId !== session.peer.agentId ||
510
      typeof credential.grant.expiresAt !== 'number' ||
511
      credential.grant.expiresAt <= Date.now() ||
512
      Date.parse(session.expiresAt) <= Date.now()
513
    )
514
      return Response.json({ error: 'invalid_session_credential' }, { status: 401 });
515
  } catch {
516
    return Response.json({ error: 'invalid_session_credential' }, { status: 401 });
517
  }
518
  try {
519
    const result = await input.onMessage({ session, requestId: rpc.id, message: rpc.params });
520
    return Response.json({ jsonrpc: '2.0', id: rpc.id, result });
521
  } catch (error) {
522
    return Response.json(
523
      {
524
        jsonrpc: '2.0',
525
        id: rpc.id,
526
        error: { code: -32000, message: error instanceof Error ? error.message : 'Agent failed' },
527
      },
528
      { status: 500 },
529
    );
530
  }
531
}
532
533
export async function sendOpenClaspDirectMessage(
534
  session: LiveSessionActivation,
535
  payload: unknown,
536
  requestId = crypto.randomUUID(),
537
) {
538
  const message =
539
    payload && typeof payload === 'object'
540
      ? {
541
          ...(payload as Record<string, unknown>),
542
          metadata: {
543
            ...(((payload as Record<string, unknown>).metadata as Record<string, unknown>) ?? {}),
544
            [DEFAULT_EXTENSION_URI]: {
545
              interactionId: session.interactionId,
546
              termsHash: session.contractHash,
547
              initiatorAgentId:
548
                session.role === 'initiator' ? session.agentId : session.peer.agentId,
549
              responderAgentId:
550
                session.role === 'responder' ? session.agentId : session.peer.agentId,
551
            },
552
          },
553
        }
554
      : payload;
555
  const response = await fetch(session.peer.endpoint, {
556
    method: 'POST',
557
    headers: {
558
      authorization: `Bearer ${session.peer.bearerToken}`,
559
      'content-type': 'application/json',
560
      'A2A-Extensions': DEFAULT_EXTENSION_URI,
561
    },
562
    body: JSON.stringify({
563
      jsonrpc: '2.0',
564
      id: requestId,
565
      method: 'message/send',
566
      params: { message },
567
    }),
568
  });
569
  const result = (await response.json()) as unknown;
570
  if (!response.ok) throw new Error(`Direct agent request failed with HTTP ${response.status}`);
571
  return result;
572
}
573
574
export async function reportOpenClaspSessionEvent(
575
  session: LiveSessionActivation,
576
  event: LiveSessionEvent,
577
) {
578
  const value = LiveSessionEventSchema.parse(event);
579
  if (value.interactionId !== session.interactionId || value.agentId !== session.agentId)
580
    throw new Error('Session event does not match the active session');
581
  const response = await fetch(session.reporting.endpoint, {
582
    method: 'POST',
583
    headers: {
584
      authorization: `Bearer ${session.reporting.bearerToken}`,
585
      'content-type': 'application/json',
586
    },
587
    body: JSON.stringify(value),
588
  });
589
  const result = (await response.json()) as unknown;
590
  if (!response.ok) throw new Error(`OpenClasp event report failed with HTTP ${response.status}`);
591
  return result;
592
}