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