| 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 | }
|