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

Viewing files at commit 00864c3cb723Commit detailsView latest

packages/sdk/src/index.ts 589 lines · 20.0 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
  heartbeatRuntime(): Promise<{ status: 'online' | 'offline'; checkedAt: string }> {
164
    return this.request('/runtime/heartbeat', { method: 'POST' });
165
  }
166
}
167
168
export type RuntimeBootstrap = {
169
  agentId: string;
170
  openClaspUrl: string;
171
  runtimeRegistrationEndpoint: string;
172
  protocol: 'A2A/1.0';
173
  controlProtocol: 'OpenClasp/0.1';
174
};
175
176
export type RuntimeConnection = {
177
  agentId: string;
178
  endpoint: string;
179
  a2aEndpoint: string;
180
  status: 'verified';
181
  verifiedAt: string;
182
  verificationKey: string;
183
};
184
185
export function createSignedEvent(
186
  input: Omit<InteractionEvent, 'payloadHash' | 'signature'>,
187
  key: KeyPair,
188
): InteractionEvent {
189
  return signObject(
190
    { ...input, payloadHash: canonicalHash(input.payload) },
191
    key,
192
  ) as InteractionEvent;
193
}
194
195
export function createSignedEnvelope(
196
  input: Omit<TrustEnvelope, 'signature'>,
197
  key: KeyPair,
198
): TrustEnvelope {
199
  return signObject(input, key) as TrustEnvelope;
200
}
201
202
function validRuntimeSignature(
203
  publicKey: string,
204
  requestId: string,
205
  timestamp: string,
206
  body: string,
207
  signature: string,
208
) {
209
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
210
  return verify(
211
    null,
212
    Buffer.from(`${timestamp}.${requestId}.${body}`),
213
    createPublicKey({ key: Buffer.from(publicKey, 'base64url'), type: 'spki', format: 'der' }),
214
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
215
  );
216
}
217
218
export function createOpenClaspRuntimeHandler(input: {
219
  agentId: string;
220
  a2aEndpoint: string;
221
  openClaspUrl?: string;
222
  openClaspVerificationKey?: string;
223
  onSessionOffer: (
224
    offer: LiveSessionOffer,
225
  ) =>
226
    Promise<{ accepted: boolean; sessionId?: string }> | { accepted: boolean; sessionId?: string };
227
  onSessionActivated: (session: LiveSessionActivation) => Promise<void> | void;
228
  loadSession: (interactionId: string) => Promise<LiveSessionActivation | undefined>;
229
  onMessage: (input: {
230
    session: LiveSessionActivation;
231
    requestId: string | number;
232
    message: unknown;
233
  }) => Promise<unknown> | unknown;
234
}) {
235
  let verificationKeyPromise: Promise<string> | undefined;
236
  const verificationKey = () => {
237
    if (input.openClaspVerificationKey) return Promise.resolve(input.openClaspVerificationKey);
238
    return (verificationKeyPromise ??= fetch(
239
      `${(input.openClaspUrl ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/.well-known/openclasp-session-key`,
240
    )
241
      .then(async (response) => {
242
        if (!response.ok) throw new Error(`OpenClasp key discovery failed: ${response.status}`);
243
        const value = (await response.json()) as { publicKey?: unknown };
244
        if (typeof value.publicKey !== 'string')
245
          throw new Error('Invalid OpenClasp verification key');
246
        return value.publicKey;
247
      })
248
      .catch((error) => {
249
        verificationKeyPromise = undefined;
250
        throw error;
251
      }));
252
  };
253
  return async (request: Request): Promise<Response> => {
254
    const body = await request.text();
255
    let parsed: Record<string, unknown>;
256
    try {
257
      parsed = JSON.parse(body) as Record<string, unknown>;
258
    } catch {
259
      return Response.json({ error: 'invalid_json' }, { status: 400 });
260
    }
261
    if (parsed.type === 'openclasp.runtime.verify')
262
      return Response.json({
263
        type: 'openclasp.runtime.verified',
264
        version: '1',
265
        agentId: parsed.agentId,
266
        challenge: parsed.challenge,
267
        a2aEndpoint: input.a2aEndpoint,
268
      });
269
    if (parsed.type !== 'openclasp.session.offer' && parsed.type !== 'openclasp.session.activation')
270
      return handleDirectMessage(request, parsed, input);
271
    const requestId = request.headers.get('openclasp-request-id') ?? '';
272
    const timestamp = request.headers.get('openclasp-timestamp') ?? '';
273
    const signature = request.headers.get('openclasp-signature') ?? '';
274
    let signatureValid = false;
275
    try {
276
      signatureValid = Boolean(
277
        requestId &&
278
        timestamp &&
279
        signature &&
280
        validRuntimeSignature(await verificationKey(), requestId, timestamp, body, signature),
281
      );
282
    } catch {
283
      return Response.json({ error: 'openclasp_key_unavailable' }, { status: 503 });
284
    }
285
    if (!signatureValid)
286
      return Response.json({ error: 'invalid_openclasp_signature' }, { status: 401 });
287
    if (parsed.type === 'openclasp.session.offer') {
288
      const result = LiveSessionOfferSchema.safeParse(parsed);
289
      if (
290
        !result.success ||
291
        result.data.offerId !== requestId ||
292
        result.data.agentId !== input.agentId
293
      )
294
        return Response.json({ error: 'invalid_session_offer' }, { status: 400 });
295
      const decision = await input.onSessionOffer(result.data);
296
      if (!decision.accepted) return Response.json({ error: 'session_declined' }, { status: 409 });
297
      if (!decision.sessionId)
298
        return Response.json({ error: 'durable_session_id_required' }, { status: 500 });
299
      return Response.json({
300
        type: 'openclasp.session.accepted',
301
        version: '1',
302
        offerId: result.data.offerId,
303
        interactionId: result.data.interactionId,
304
        agentId: input.agentId,
305
        sessionId: decision.sessionId,
306
        a2aEndpoint: input.a2aEndpoint,
307
        expiresAt: result.data.expiresAt,
308
      });
309
    }
310
    const activation = LiveSessionActivationSchema.safeParse(parsed);
311
    if (
312
      !activation.success ||
313
      activation.data.activationId !== requestId ||
314
      activation.data.agentId !== input.agentId
315
    )
316
      return Response.json({ error: 'invalid_session_activation' }, { status: 400 });
317
    await input.onSessionActivated(activation.data);
318
    return Response.json({ accepted: true, activationId: activation.data.activationId });
319
  };
320
}
321
322
type RuntimeHandlerInput = Parameters<typeof createOpenClaspRuntimeHandler>[0];
323
324
export interface RuntimeSessionStore {
325
  get(interactionId: string): Promise<LiveSessionActivation | undefined>;
326
  put(interactionId: string, session: LiveSessionActivation): Promise<void>;
327
}
328
329
export interface AgentRuntimeAdapter {
330
  readonly name: string;
331
  prepareSession(
332
    offer: LiveSessionOffer,
333
  ): Promise<{ accepted: boolean; sessionId?: string }> | { accepted: boolean; sessionId?: string };
334
  activateSession(session: LiveSessionActivation): Promise<void> | void;
335
  receiveMessage(input: {
336
    session: LiveSessionActivation;
337
    requestId: string | number;
338
    message: unknown;
339
  }): Promise<unknown> | unknown;
340
}
341
342
/** In-memory storage is useful for tests only. Production runtimes must use durable storage. */
343
export class MemoryRuntimeSessionStore implements RuntimeSessionStore {
344
  readonly sessions = new Map<string, LiveSessionActivation>();
345
346
  async get(interactionId: string) {
347
    return this.sessions.get(interactionId);
348
  }
349
350
  async put(interactionId: string, session: LiveSessionActivation) {
351
    this.sessions.set(interactionId, session);
352
  }
353
}
354
355
export function createAgentRuntimeConnector(input: {
356
  agentId: string;
357
  a2aEndpoint: string;
358
  adapter: AgentRuntimeAdapter;
359
  sessions: RuntimeSessionStore;
360
  openClaspUrl?: string;
361
  openClaspVerificationKey?: string;
362
}) {
363
  return createOpenClaspRuntimeHandler({
364
    agentId: input.agentId,
365
    a2aEndpoint: input.a2aEndpoint,
366
    ...(input.openClaspUrl ? { openClaspUrl: input.openClaspUrl } : {}),
367
    ...(input.openClaspVerificationKey
368
      ? { openClaspVerificationKey: input.openClaspVerificationKey }
369
      : {}),
370
    async onSessionOffer(offer) {
371
      const decision = await input.adapter.prepareSession(offer);
372
      return decision.accepted
373
        ? { accepted: true, sessionId: decision.sessionId ?? crypto.randomUUID() }
374
        : { accepted: false };
375
    },
376
    async onSessionActivated(session) {
377
      await input.sessions.put(session.interactionId, session);
378
      await input.adapter.activateSession(session);
379
    },
380
    loadSession: (interactionId) => input.sessions.get(interactionId),
381
    onMessage: (message) => input.adapter.receiveMessage(message),
382
  });
383
}
384
385
export class HttpAgentRuntimeAdapter implements AgentRuntimeAdapter {
386
  readonly name = 'http-webhook';
387
  private readonly baseUrl: string;
388
389
  constructor(
390
    baseUrl: string,
391
    private readonly options: {
392
      bearerToken?: string;
393
      timeoutMs?: number;
394
    } = {},
395
  ) {
396
    this.baseUrl = baseUrl.replace(/\/$/, '');
397
  }
398
399
  private async post<T>(path: string, body: unknown): Promise<T> {
400
    const response = await fetch(`${this.baseUrl}${path}`, {
401
      method: 'POST',
402
      headers: {
403
        'content-type': 'application/json',
404
        ...(this.options.bearerToken
405
          ? { authorization: `Bearer ${this.options.bearerToken}` }
406
          : {}),
407
      },
408
      body: JSON.stringify(body),
409
      signal: AbortSignal.timeout(this.options.timeoutMs ?? 30_000),
410
    });
411
    const text = await response.text();
412
    let value: unknown = {};
413
    if (text) {
414
      try {
415
        value = JSON.parse(text);
416
      } catch {
417
        throw new Error(`Agent adapter returned non-JSON from ${path}`);
418
      }
419
    }
420
    if (!response.ok) {
421
      const error = value as { error?: unknown };
422
      throw new Error(
423
        typeof error.error === 'string'
424
          ? error.error
425
          : `Agent adapter ${path} returned HTTP ${response.status}`,
426
      );
427
    }
428
    return value as T;
429
  }
430
431
  async prepareSession(offer: LiveSessionOffer) {
432
    const decision = await this.post<{ accepted?: unknown; sessionId?: unknown }>(
433
      '/openclasp/session-offer',
434
      offer,
435
    );
436
    return {
437
      accepted: decision.accepted === true,
438
      ...(typeof decision.sessionId === 'string' ? { sessionId: decision.sessionId } : {}),
439
    };
440
  }
441
442
  async activateSession(session: LiveSessionActivation) {
443
    await this.post('/openclasp/session-activated', session);
444
  }
445
446
  receiveMessage(input: {
447
    session: LiveSessionActivation;
448
    requestId: string | number;
449
    message: unknown;
450
  }) {
451
    return this.post('/openclasp/message', input);
452
  }
453
}
454
455
function decodeSessionCredential(token: string) {
456
  const [payload, signature] = token.split('.');
457
  if (!payload || !signature) throw new Error('Invalid live-session credential');
458
  return {
459
    payload,
460
    signature,
461
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
462
      interactionId?: unknown;
463
      senderAgentId?: unknown;
464
      recipientAgentId?: unknown;
465
      expiresAt?: unknown;
466
    },
467
  };
468
}
469
470
async function handleDirectMessage(
471
  request: Request,
472
  parsed: Record<string, unknown>,
473
  input: RuntimeHandlerInput,
474
) {
475
  const rpc = parsed as { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
476
  if (
477
    rpc.jsonrpc !== '2.0' ||
478
    (typeof rpc.id !== 'string' && typeof rpc.id !== 'number') ||
479
    rpc.method !== 'message/send'
480
  )
481
    return Response.json({ error: 'invalid_a2a_request' }, { status: 400 });
482
  const authorization = request.headers.get('authorization');
483
  if (!authorization?.startsWith('Bearer '))
484
    return Response.json({ error: 'session_credential_required' }, { status: 401 });
485
  let session: LiveSessionActivation;
486
  try {
487
    const credential = decodeSessionCredential(authorization.slice(7));
488
    if (typeof credential.grant.interactionId !== 'string')
489
      throw new Error('Invalid interaction credential');
490
    const loaded = await input.loadSession(credential.grant.interactionId);
491
    if (!loaded) return Response.json({ error: 'live_session_not_found' }, { status: 404 });
492
    session = loaded;
493
    const valid = verify(
494
      null,
495
      Buffer.from(credential.payload),
496
      createPublicKey({
497
        key: Buffer.from(session.peer.verificationKey, 'base64url'),
498
        type: 'spki',
499
        format: 'der',
500
      }),
501
      Buffer.from(credential.signature, 'base64url'),
502
    );
503
    if (
504
      !valid ||
505
      credential.grant.recipientAgentId !== input.agentId ||
506
      credential.grant.senderAgentId !== session.peer.agentId ||
507
      typeof credential.grant.expiresAt !== 'number' ||
508
      credential.grant.expiresAt <= Date.now() ||
509
      Date.parse(session.expiresAt) <= Date.now()
510
    )
511
      return Response.json({ error: 'invalid_session_credential' }, { status: 401 });
512
  } catch {
513
    return Response.json({ error: 'invalid_session_credential' }, { status: 401 });
514
  }
515
  try {
516
    const result = await input.onMessage({ session, requestId: rpc.id, message: rpc.params });
517
    return Response.json({ jsonrpc: '2.0', id: rpc.id, result });
518
  } catch (error) {
519
    return Response.json(
520
      {
521
        jsonrpc: '2.0',
522
        id: rpc.id,
523
        error: { code: -32000, message: error instanceof Error ? error.message : 'Agent failed' },
524
      },
525
      { status: 500 },
526
    );
527
  }
528
}
529
530
export async function sendOpenClaspDirectMessage(
531
  session: LiveSessionActivation,
532
  payload: unknown,
533
  requestId = crypto.randomUUID(),
534
) {
535
  const message =
536
    payload && typeof payload === 'object'
537
      ? {
538
          ...(payload as Record<string, unknown>),
539
          metadata: {
540
            ...(((payload as Record<string, unknown>).metadata as Record<string, unknown>) ?? {}),
541
            [DEFAULT_EXTENSION_URI]: {
542
              interactionId: session.interactionId,
543
              termsHash: session.contractHash,
544
              initiatorAgentId:
545
                session.role === 'initiator' ? session.agentId : session.peer.agentId,
546
              responderAgentId:
547
                session.role === 'responder' ? session.agentId : session.peer.agentId,
548
            },
549
          },
550
        }
551
      : payload;
552
  const response = await fetch(session.peer.endpoint, {
553
    method: 'POST',
554
    headers: {
555
      authorization: `Bearer ${session.peer.bearerToken}`,
556
      'content-type': 'application/json',
557
      'A2A-Extensions': DEFAULT_EXTENSION_URI,
558
    },
559
    body: JSON.stringify({
560
      jsonrpc: '2.0',
561
      id: requestId,
562
      method: 'message/send',
563
      params: { message },
564
    }),
565
  });
566
  const result = (await response.json()) as unknown;
567
  if (!response.ok) throw new Error(`Direct agent request failed with HTTP ${response.status}`);
568
  return result;
569
}
570
571
export async function reportOpenClaspSessionEvent(
572
  session: LiveSessionActivation,
573
  event: LiveSessionEvent,
574
) {
575
  const value = LiveSessionEventSchema.parse(event);
576
  if (value.interactionId !== session.interactionId || value.agentId !== session.agentId)
577
    throw new Error('Session event does not match the active session');
578
  const response = await fetch(session.reporting.endpoint, {
579
    method: 'POST',
580
    headers: {
581
      authorization: `Bearer ${session.reporting.bearerToken}`,
582
      'content-type': 'application/json',
583
    },
584
    body: JSON.stringify(value),
585
  });
586
  const result = (await response.json()) as unknown;
587
  if (!response.ok) throw new Error(`OpenClasp event report failed with HTTP ${response.status}`);
588
  return result;
589
}