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

Viewing files at commit 296bb55574a2Commit detailsView latest

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