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