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

Viewing files at commit 39e235ac59f4Commit detailsView latest

packages/sdk/src/index.ts 427 lines · 15.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
        'content-type': 'application/json',
33
        ...(this.accessToken ? { authorization: `Bearer ${this.accessToken}` } : {}),
34
        ...init?.headers,
35
      },
36
    });
37
    const body = (await response.json()) as any;
38
    if (!response.ok) throw new Error(body.error ?? `OpenClasp request failed: ${response.status}`);
39
    return body as T;
40
  }
41
  registerAgent(identity: unknown) {
42
    return this.request('/agents', { method: 'POST', body: JSON.stringify(identity) });
43
  }
44
  getProfile(agentId: string, version: string, taskCategory: string) {
45
    return this.request(
46
      `/profiles/${encodeURIComponent(agentId)}?version=${encodeURIComponent(version)}&taskCategory=${encodeURIComponent(taskCategory)}`,
47
    );
48
  }
49
  saveContract(contract: unknown) {
50
    return this.request('/interactions/contracts', {
51
      method: 'POST',
52
      body: JSON.stringify(contract),
53
    });
54
  }
55
  assess(input: unknown) {
56
    return this.request('/risk/assess', { method: 'POST', body: JSON.stringify(input) });
57
  }
58
  recordEvent(event: unknown) {
59
    return this.request('/events', { method: 'POST', body: JSON.stringify(event) });
60
  }
61
  checkClaim(claim: string) {
62
    return this.request('/claims/check', { method: 'POST', body: JSON.stringify({ claim }) });
63
  }
64
  submitReceipt(receipt: unknown) {
65
    return this.request('/receipts', { method: 'POST', body: JSON.stringify(receipt) });
66
  }
67
  verifyReceipt(receipt: unknown) {
68
    return this.request('/receipts/verify', { method: 'POST', body: JSON.stringify(receipt) });
69
  }
70
  submitFeedback(feedback: unknown) {
71
    return this.request('/feedback', { method: 'POST', body: JSON.stringify(feedback) });
72
  }
73
  setContributionConsent(agentId: string, enabled: boolean) {
74
    return this.request('/contributions/consent', {
75
      method: 'POST',
76
      body: JSON.stringify({ agentId, enabled }),
77
    });
78
  }
79
  getPublicAgentCard(agentId: string): Promise<PublicAgentCard> {
80
    const root = this.baseUrl.replace(/\/v0\.1\/?$/, '');
81
    return fetch(`${root}/agents/${encodeURIComponent(agentId)}/card.json`).then(
82
      async (response) => {
83
        const body = (await response.json()) as any;
84
        if (!response.ok)
85
          throw new Error(body.error ?? `OpenClasp request failed: ${response.status}`);
86
        return body as PublicAgentCard;
87
      },
88
    );
89
  }
90
  createFederatedInteraction(value: FederatedInteraction) {
91
    return this.request<FederatedInteraction>('/federated-interactions', {
92
      method: 'POST',
93
      body: JSON.stringify(value),
94
    });
95
  }
96
  listFederatedInteractions() {
97
    return this.request<FederatedInteraction[]>('/federated-interactions');
98
  }
99
  getFederatedInteraction(interactionId: string) {
100
    return this.request<FederatedInteraction>(
101
      `/federated-interactions/${encodeURIComponent(interactionId)}`,
102
    );
103
  }
104
  getLiveSession(interactionId: string, agentId: string): Promise<LiveSessionActivation> {
105
    return this.request(
106
      `/federated-interactions/${encodeURIComponent(interactionId)}/session?agentId=${encodeURIComponent(agentId)}`,
107
    );
108
  }
109
  respondToFederatedInteraction(
110
    interactionId: string,
111
    agentId: string,
112
    decision: 'accept' | 'reject',
113
  ) {
114
    return this.request<FederatedInteraction>(
115
      `/federated-interactions/${encodeURIComponent(interactionId)}/respond`,
116
      { method: 'POST', body: JSON.stringify({ agentId, decision }) },
117
    );
118
  }
119
  listHostedThreads(agentId: string): Promise<HostedThread[]> {
120
    return this.request(`/agents/${encodeURIComponent(agentId)}/threads`);
121
  }
122
  getHostedThread(
123
    agentId: string,
124
    threadId: string,
125
  ): Promise<{
126
    thread: HostedThread;
127
    messages: HostedMessage[];
128
    insights: unknown[];
129
  }> {
130
    return this.request(
131
      `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}`,
132
    );
133
  }
134
  sendTemporaryMessage(agentId: string, interactionId: string, content: string) {
135
    return this.request(`/agents/${encodeURIComponent(agentId)}/messages`, {
136
      method: 'POST',
137
      body: JSON.stringify({ interactionId, content }),
138
    });
139
  }
140
  markHostedThreadRead(agentId: string, threadId: string) {
141
    return this.request(
142
      `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}/read`,
143
      { method: 'POST' },
144
    );
145
  }
146
  closeHostedThread(agentId: string, threadId: string) {
147
    return this.request(
148
      `/agents/${encodeURIComponent(agentId)}/threads/${encodeURIComponent(threadId)}/close`,
149
      { method: 'POST' },
150
    );
151
  }
152
}
153
154
export function createSignedEvent(
155
  input: Omit<InteractionEvent, 'payloadHash' | 'signature'>,
156
  key: KeyPair,
157
): InteractionEvent {
158
  return signObject(
159
    { ...input, payloadHash: canonicalHash(input.payload) },
160
    key,
161
  ) as InteractionEvent;
162
}
163
164
export function createSignedEnvelope(
165
  input: Omit<TrustEnvelope, 'signature'>,
166
  key: KeyPair,
167
): TrustEnvelope {
168
  return signObject(input, key) as TrustEnvelope;
169
}
170
171
function validRuntimeSignature(
172
  publicKey: string,
173
  requestId: string,
174
  timestamp: string,
175
  body: string,
176
  signature: string,
177
) {
178
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
179
  return verify(
180
    null,
181
    Buffer.from(`${timestamp}.${requestId}.${body}`),
182
    createPublicKey({ key: Buffer.from(publicKey, 'base64url'), type: 'spki', format: 'der' }),
183
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
184
  );
185
}
186
187
export function createOpenClaspRuntimeHandler(input: {
188
  agentId: string;
189
  a2aEndpoint: string;
190
  openClaspUrl?: string;
191
  openClaspVerificationKey?: string;
192
  onSessionOffer: (
193
    offer: LiveSessionOffer,
194
  ) =>
195
    Promise<{ accepted: boolean; sessionId?: string }> | { accepted: boolean; sessionId?: string };
196
  onSessionActivated: (session: LiveSessionActivation) => Promise<void> | void;
197
  loadSession: (interactionId: string) => Promise<LiveSessionActivation | undefined>;
198
  onMessage: (input: {
199
    session: LiveSessionActivation;
200
    requestId: string | number;
201
    message: unknown;
202
  }) => Promise<unknown> | unknown;
203
}) {
204
  let verificationKeyPromise: Promise<string> | undefined;
205
  const verificationKey = () => {
206
    if (input.openClaspVerificationKey) return Promise.resolve(input.openClaspVerificationKey);
207
    return (verificationKeyPromise ??= fetch(
208
      `${(input.openClaspUrl ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/.well-known/openclasp-session-key`,
209
    )
210
      .then(async (response) => {
211
        if (!response.ok) throw new Error(`OpenClasp key discovery failed: ${response.status}`);
212
        const value = (await response.json()) as { publicKey?: unknown };
213
        if (typeof value.publicKey !== 'string')
214
          throw new Error('Invalid OpenClasp verification key');
215
        return value.publicKey;
216
      })
217
      .catch((error) => {
218
        verificationKeyPromise = undefined;
219
        throw error;
220
      }));
221
  };
222
  return async (request: Request): Promise<Response> => {
223
    const body = await request.text();
224
    let parsed: Record<string, unknown>;
225
    try {
226
      parsed = JSON.parse(body) as Record<string, unknown>;
227
    } catch {
228
      return Response.json({ error: 'invalid_json' }, { status: 400 });
229
    }
230
    if (parsed.type === 'openclasp.runtime.verify')
231
      return Response.json({
232
        type: 'openclasp.runtime.verified',
233
        version: '1',
234
        agentId: parsed.agentId,
235
        challenge: parsed.challenge,
236
        a2aEndpoint: input.a2aEndpoint,
237
      });
238
    if (parsed.type !== 'openclasp.session.offer' && parsed.type !== 'openclasp.session.activation')
239
      return handleDirectMessage(request, parsed, input);
240
    const requestId = request.headers.get('openclasp-request-id') ?? '';
241
    const timestamp = request.headers.get('openclasp-timestamp') ?? '';
242
    const signature = request.headers.get('openclasp-signature') ?? '';
243
    let signatureValid = false;
244
    try {
245
      signatureValid = Boolean(
246
        requestId &&
247
        timestamp &&
248
        signature &&
249
        validRuntimeSignature(await verificationKey(), requestId, timestamp, body, signature),
250
      );
251
    } catch {
252
      return Response.json({ error: 'openclasp_key_unavailable' }, { status: 503 });
253
    }
254
    if (!signatureValid)
255
      return Response.json({ error: 'invalid_openclasp_signature' }, { status: 401 });
256
    if (parsed.type === 'openclasp.session.offer') {
257
      const result = LiveSessionOfferSchema.safeParse(parsed);
258
      if (
259
        !result.success ||
260
        result.data.offerId !== requestId ||
261
        result.data.agentId !== input.agentId
262
      )
263
        return Response.json({ error: 'invalid_session_offer' }, { status: 400 });
264
      const decision = await input.onSessionOffer(result.data);
265
      if (!decision.accepted) return Response.json({ error: 'session_declined' }, { status: 409 });
266
      if (!decision.sessionId)
267
        return Response.json({ error: 'durable_session_id_required' }, { status: 500 });
268
      return Response.json({
269
        type: 'openclasp.session.accepted',
270
        version: '1',
271
        offerId: result.data.offerId,
272
        interactionId: result.data.interactionId,
273
        agentId: input.agentId,
274
        sessionId: decision.sessionId,
275
        a2aEndpoint: input.a2aEndpoint,
276
        expiresAt: result.data.expiresAt,
277
      });
278
    }
279
    const activation = LiveSessionActivationSchema.safeParse(parsed);
280
    if (
281
      !activation.success ||
282
      activation.data.activationId !== requestId ||
283
      activation.data.agentId !== input.agentId
284
    )
285
      return Response.json({ error: 'invalid_session_activation' }, { status: 400 });
286
    await input.onSessionActivated(activation.data);
287
    return Response.json({ accepted: true, activationId: activation.data.activationId });
288
  };
289
}
290
291
type RuntimeHandlerInput = Parameters<typeof createOpenClaspRuntimeHandler>[0];
292
293
function decodeSessionCredential(token: string) {
294
  const [payload, signature] = token.split('.');
295
  if (!payload || !signature) throw new Error('Invalid live-session credential');
296
  return {
297
    payload,
298
    signature,
299
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
300
      interactionId?: unknown;
301
      senderAgentId?: unknown;
302
      recipientAgentId?: unknown;
303
      expiresAt?: unknown;
304
    },
305
  };
306
}
307
308
async function handleDirectMessage(
309
  request: Request,
310
  parsed: Record<string, unknown>,
311
  input: RuntimeHandlerInput,
312
) {
313
  const rpc = parsed as { jsonrpc?: unknown; id?: unknown; method?: unknown; params?: unknown };
314
  if (
315
    rpc.jsonrpc !== '2.0' ||
316
    (typeof rpc.id !== 'string' && typeof rpc.id !== 'number') ||
317
    rpc.method !== 'message/send'
318
  )
319
    return Response.json({ error: 'invalid_a2a_request' }, { status: 400 });
320
  const authorization = request.headers.get('authorization');
321
  if (!authorization?.startsWith('Bearer '))
322
    return Response.json({ error: 'session_credential_required' }, { status: 401 });
323
  let session: LiveSessionActivation;
324
  try {
325
    const credential = decodeSessionCredential(authorization.slice(7));
326
    if (typeof credential.grant.interactionId !== 'string')
327
      throw new Error('Invalid interaction credential');
328
    const loaded = await input.loadSession(credential.grant.interactionId);
329
    if (!loaded) return Response.json({ error: 'live_session_not_found' }, { status: 404 });
330
    session = loaded;
331
    const valid = verify(
332
      null,
333
      Buffer.from(credential.payload),
334
      createPublicKey({
335
        key: Buffer.from(session.peer.verificationKey, 'base64url'),
336
        type: 'spki',
337
        format: 'der',
338
      }),
339
      Buffer.from(credential.signature, 'base64url'),
340
    );
341
    if (
342
      !valid ||
343
      credential.grant.recipientAgentId !== input.agentId ||
344
      credential.grant.senderAgentId !== session.peer.agentId ||
345
      typeof credential.grant.expiresAt !== 'number' ||
346
      credential.grant.expiresAt <= Date.now() ||
347
      Date.parse(session.expiresAt) <= Date.now()
348
    )
349
      return Response.json({ error: 'invalid_session_credential' }, { status: 401 });
350
  } catch {
351
    return Response.json({ error: 'invalid_session_credential' }, { status: 401 });
352
  }
353
  try {
354
    const result = await input.onMessage({ session, requestId: rpc.id, message: rpc.params });
355
    return Response.json({ jsonrpc: '2.0', id: rpc.id, result });
356
  } catch (error) {
357
    return Response.json(
358
      {
359
        jsonrpc: '2.0',
360
        id: rpc.id,
361
        error: { code: -32000, message: error instanceof Error ? error.message : 'Agent failed' },
362
      },
363
      { status: 500 },
364
    );
365
  }
366
}
367
368
export async function sendOpenClaspDirectMessage(
369
  session: LiveSessionActivation,
370
  payload: unknown,
371
  requestId = crypto.randomUUID(),
372
) {
373
  const message =
374
    payload && typeof payload === 'object'
375
      ? {
376
          ...(payload as Record<string, unknown>),
377
          metadata: {
378
            ...(((payload as Record<string, unknown>).metadata as Record<string, unknown>) ?? {}),
379
            [DEFAULT_EXTENSION_URI]: {
380
              interactionId: session.interactionId,
381
              termsHash: session.contractHash,
382
              initiatorAgentId:
383
                session.role === 'initiator' ? session.agentId : session.peer.agentId,
384
              responderAgentId:
385
                session.role === 'responder' ? session.agentId : session.peer.agentId,
386
            },
387
          },
388
        }
389
      : payload;
390
  const response = await fetch(session.peer.endpoint, {
391
    method: 'POST',
392
    headers: {
393
      authorization: `Bearer ${session.peer.bearerToken}`,
394
      'content-type': 'application/json',
395
      'A2A-Extensions': DEFAULT_EXTENSION_URI,
396
    },
397
    body: JSON.stringify({
398
      jsonrpc: '2.0',
399
      id: requestId,
400
      method: 'message/send',
401
      params: { message },
402
    }),
403
  });
404
  const result = (await response.json()) as unknown;
405
  if (!response.ok) throw new Error(`Direct agent request failed with HTTP ${response.status}`);
406
  return result;
407
}
408
409
export async function reportOpenClaspSessionEvent(
410
  session: LiveSessionActivation,
411
  event: LiveSessionEvent,
412
) {
413
  const value = LiveSessionEventSchema.parse(event);
414
  if (value.interactionId !== session.interactionId || value.agentId !== session.agentId)
415
    throw new Error('Session event does not match the active session');
416
  const response = await fetch(session.reporting.endpoint, {
417
    method: 'POST',
418
    headers: {
419
      authorization: `Bearer ${session.reporting.bearerToken}`,
420
      'content-type': 'application/json',
421
    },
422
    body: JSON.stringify(value),
423
  });
424
  const result = (await response.json()) as unknown;
425
  if (!response.ok) throw new Error(`OpenClasp event report failed with HTTP ${response.status}`);
426
  return result;
427
}