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

Viewing files at commit 8135358a54bcCommit detailsView latest

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