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

Viewing files at commit 5b7999fcbb0cCommit detailsView latest

tests/api.test.ts 608 lines · 20.5 KB · typescript
1
import { describe, expect, it } from 'vitest';
2
import { buildApi } from '../apps/api/src/app.js';
3
4
describe('HTTP API', () => {
5
  it('serves health, readiness, and OpenAPI', async () => {
6
    const app = buildApi();
7
    await app.ready();
8
    expect((await app.inject({ method: 'GET', url: '/health' })).statusCode).toBe(200);
9
    expect((await app.inject({ method: 'GET', url: '/ready' })).json()).toEqual({
10
      status: 'ready',
11
    });
12
    expect(
13
      (await app.inject({ method: 'GET', url: '/extensions/trust/v0.1' })).json(),
14
    ).toMatchObject({ version: '0.1', transportsMessages: false });
15
    const specification = (await app.inject({ method: 'GET', url: '/openapi.json' })).json();
16
    expect(specification.info.title).toBe('OpenClasp API');
17
    expect(specification.paths).toHaveProperty('/v0.1/risk/assess');
18
    await app.close();
19
  });
20
21
  it('isolates hosted dashboard and settings by authenticated operator', async () => {
22
    const calls: string[] = [];
23
    const repository = {
24
      dashboard: async (operatorId: string) => {
25
        calls.push(`dashboard:${operatorId}`);
26
        return {
27
          agents: [],
28
          projects: [],
29
          installations: [],
30
          setupRequests: [],
31
          publications: [],
32
          interactions: [],
33
          federatedInteractions: [],
34
          liveSessions: [],
35
          hostedThreads: [],
36
          events: [],
37
          conflicts: [],
38
          receipts: [],
39
          profiles: [],
40
          counterpartyBriefs: [],
41
          completionReports: [],
42
          feedbackRequests: [],
43
          interactionFeedback: [],
44
          interactionConclusions: [],
45
          learningEligibility: [],
46
          profileDeltas: [],
47
          runtimes: [],
48
          accessTokens: [],
49
        };
50
      },
51
      getSettings: async (operatorId: string) => {
52
        calls.push(`settings:${operatorId}`);
53
        return {
54
          displayName: '',
55
          contributionEnabled: false,
56
          retentionDays: 30,
57
          evidenceSharing: 'ask' as const,
58
          rawConversationsStored: false as const,
59
        };
60
      },
61
      saveSettings: async (operatorId: string, value: any) => {
62
        calls.push(`save:${operatorId}`);
63
        return { ...value, rawConversationsStored: false as const };
64
      },
65
      upsert: async () => undefined,
66
      list: async () => [],
67
      publishAgent: async (_operatorId: string, card: any) => card,
68
      unpublishAgent: async () => true,
69
      getPublishedAgent: async () => undefined,
70
      searchPublishedAgents: async () => [],
71
      registerAgentRuntime: async (operatorId: string, agentId: string, endpoint: string) => {
72
        calls.push(`runtime:${operatorId}:${agentId}:${endpoint}`);
73
        return {
74
          agentId,
75
          endpoint,
76
          a2aEndpoint: endpoint,
77
          status: 'verified' as const,
78
          verifiedAt: new Date().toISOString(),
79
          verificationKey: 'public-key',
80
        };
81
      },
82
      touchAgentPresence: async (operatorId: string, agentId: string) => {
83
        calls.push(`heartbeat:${operatorId}:${agentId}`);
84
        return {
85
          status: 'online' as const,
86
          checkedAt: '2026-01-01T00:00:00.000Z',
87
        };
88
      },
89
      disableAgentRuntime: async (operatorId: string, agentId: string) => {
90
        calls.push(`disable-runtime:${operatorId}:${agentId}`);
91
        return { agentId, status: 'disabled' as const };
92
      },
93
      deleteAgent: async (operatorId: string, agentId: string) => {
94
        calls.push(`delete-agent:${operatorId}:${agentId}`);
95
        return { agentId, deleted: true as const, historyRetained: true as const };
96
      },
97
      listAgentAccessTokens: async (operatorId: string, agentId?: string) => {
98
        calls.push(`list-tokens:${operatorId}:${agentId ?? '*'}`);
99
        return [];
100
      },
101
      issueAgentAccessToken: async (
102
        operatorId: string,
103
        agentId: string,
104
        value: { name: string; expiresInDays: number },
105
      ) => {
106
        calls.push(`issue-token:${operatorId}:${agentId}:${value.name}:${value.expiresInDays}`);
107
        return {
108
          tokenId: 'abcdefghijklmnop',
109
          token: 'oc_at_abcdefghijklmnop.abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
110
          agentId,
111
          name: value.name,
112
          scopes: ['mcp:access', 'runtime:connect'],
113
          createdAt: '2026-01-01T00:00:00.000Z',
114
          expiresAt: '2027-01-01T00:00:00.000Z',
115
        };
116
      },
117
      revokeAgentAccessToken: async (operatorId: string, agentId: string, tokenId: string) => {
118
        calls.push(`revoke-token:${operatorId}:${agentId}:${tokenId}`);
119
        return { tokenId, agentId, revokedAt: '2026-01-02T00:00:00.000Z' };
120
      },
121
      getCounterpartyBrief: async (operatorId: string, interactionId: string, agentId: string) => {
122
        calls.push(`brief:${operatorId}:${interactionId}:${agentId}`);
123
        return {
124
          briefId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
125
          interactionId,
126
          contractHash: 'contract-hash',
127
          recipientAgentId: agentId,
128
          subjectAgentId: 'agent-b',
129
          taskCategory: 'recruiting',
130
          decision: 'CHALLENGE' as const,
131
          requirements: [],
132
          insights: [],
133
          relevantSampleSize: 0,
134
          historyConfidence: 0,
135
          subjectAgentVersion: '1.0.0',
136
          recommendedContractChanges: [],
137
          generatedAt: '2026-08-29T00:00:00.000Z',
138
          expiresAt: '2026-08-29T01:00:00.000Z',
139
        };
140
      },
141
      submitCompletionReport: async (
142
        operatorId: string,
143
        agentId: string,
144
        report: any,
145
        submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
146
      ) => {
147
        calls.push(
148
          `completion:${operatorId}:${agentId}:${report.interactionId}:${submissionMethod}`,
149
        );
150
        return report;
151
      },
152
      recordSessionCompletionReport: async (token: string, report: any) => {
153
        calls.push(`session-completion:${token}:${report.interactionId}`);
154
        return report;
155
      },
156
      receiveTemporaryMessage: async (
157
        token: string,
158
        agentId: string,
159
        requestKey: string,
160
        content: string,
161
      ) => {
162
        calls.push(`temporary:${token}:${agentId}:${requestKey}:${content}`);
163
        return {
164
          message: {
165
            messageId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
166
            threadId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
167
            interactionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
168
            senderAgentId: 'agent-peer',
169
            recipientAgentId: agentId,
170
            contentType: 'text/plain' as const,
171
            content,
172
            contentHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
173
            delivery: 'delivered' as const,
174
            createdAt: new Date().toISOString(),
175
          },
176
          deduplicated: false,
177
        };
178
      },
179
    };
180
    const app = buildApi(undefined, undefined, repository);
181
    await app.ready();
182
    expect((await app.inject({ method: 'GET', url: '/v0.1/dashboard' })).statusCode).toBe(401);
183
    expect(
184
      (
185
        await app.inject({
186
          method: 'GET',
187
          url: '/v0.1/dashboard',
188
          headers: { 'x-openclasp-operator': 'user-a' },
189
        })
190
      ).statusCode,
191
    ).toBe(200);
192
    expect(
193
      (
194
        await app.inject({
195
          method: 'GET',
196
          url: '/v0.1/settings',
197
          headers: { 'x-openclasp-operator': 'user-b' },
198
        })
199
      ).statusCode,
200
    ).toBe(200);
201
    expect(
202
      (
203
        await app.inject({
204
          method: 'PUT',
205
          url: '/v0.1/agents/agent-a/runtime',
206
          headers: { 'x-openclasp-operator': 'user-a' },
207
          payload: { endpoint: 'https://agent.example/openclasp' },
208
        })
209
      ).statusCode,
210
    ).toBe(200);
211
    expect(calls).toEqual([
212
      'dashboard:user-a',
213
      'settings:user-b',
214
      'runtime:user-a:agent-a:https://agent.example/openclasp',
215
    ]);
216
    expect(
217
      (
218
        await app.inject({
219
          method: 'GET',
220
          url: '/v0.1/runtime/bootstrap',
221
          headers: {
222
            'x-openclasp-operator': 'user-a',
223
            'x-openclasp-bound-agent': 'agent-a',
224
            host: 'openclasp.example',
225
            'x-forwarded-proto': 'https',
226
          },
227
        })
228
      ).json(),
229
    ).toMatchObject({
230
      agentId: 'agent-a',
231
      openClaspUrl: 'https://openclasp.example',
232
      protocol: 'A2A/1.0',
233
    });
234
    expect(
235
      (
236
        await app.inject({
237
          method: 'PUT',
238
          url: '/v0.1/runtime',
239
          headers: {
240
            'x-openclasp-operator': 'user-a',
241
            'x-openclasp-bound-agent': 'agent-a',
242
          },
243
          payload: { endpoint: 'https://runtime.example/a2a' },
244
        })
245
      ).statusCode,
246
    ).toBe(200);
247
    expect(calls.at(-1)).toBe('runtime:user-a:agent-a:https://runtime.example/a2a');
248
    expect(
249
      (
250
        await app.inject({
251
          method: 'DELETE',
252
          url: '/v0.1/runtime',
253
          headers: {
254
            'x-openclasp-operator': 'user-a',
255
            'x-openclasp-bound-agent': 'agent-a',
256
          },
257
        })
258
      ).statusCode,
259
    ).toBe(200);
260
    expect(calls.at(-1)).toBe('disable-runtime:user-a:agent-a');
261
    expect(
262
      (
263
        await app.inject({
264
          method: 'POST',
265
          url: '/v0.1/runtime/heartbeat',
266
          headers: {
267
            'x-openclasp-operator': 'user-a',
268
            'x-openclasp-bound-agent': 'agent-a',
269
          },
270
        })
271
      ).statusCode,
272
    ).toBe(200);
273
    expect(calls.at(-1)).toBe('heartbeat:user-a:agent-a');
274
    expect(
275
      (
276
        await app.inject({
277
          method: 'DELETE',
278
          url: '/v0.1/agents/agent-a',
279
          headers: { 'x-openclasp-operator': 'user-a' },
280
        })
281
      ).json(),
282
    ).toMatchObject({ agentId: 'agent-a', deleted: true, historyRetained: true });
283
    expect(calls.at(-1)).toBe('delete-agent:user-a:agent-a');
284
    const issuedToken = await app.inject({
285
      method: 'POST',
286
      url: '/v0.1/agents/agent-a/access-tokens',
287
      headers: { 'x-openclasp-operator': 'user-a' },
288
      payload: { name: 'Botpress', expiresInDays: 365 },
289
    });
290
    expect(issuedToken.statusCode).toBe(200);
291
    expect(issuedToken.json()).toMatchObject({
292
      agentId: 'agent-a',
293
      name: 'Botpress',
294
      scopes: ['mcp:access', 'runtime:connect'],
295
    });
296
    expect(calls.at(-1)).toBe('issue-token:user-a:agent-a:Botpress:365');
297
    expect(
298
      (
299
        await app.inject({
300
          method: 'DELETE',
301
          url: '/v0.1/agents/agent-a/access-tokens/abcdefghijklmnop',
302
          headers: { 'x-openclasp-operator': 'user-a' },
303
        })
304
      ).statusCode,
305
    ).toBe(200);
306
    expect(calls.at(-1)).toBe('revoke-token:user-a:agent-a:abcdefghijklmnop');
307
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
308
    expect(
309
      (
310
        await app.inject({
311
          method: 'GET',
312
          url: `/v0.1/federated-interactions/${interactionId}/brief?agentId=agent-a`,
313
          headers: { 'x-openclasp-operator': 'user-a' },
314
        })
315
      ).json(),
316
    ).toMatchObject({ recipientAgentId: 'agent-a', decision: 'CHALLENGE' });
317
    expect(
318
      (
319
        await app.inject({
320
          method: 'GET',
321
          url: `/v0.1/federated-interactions/${interactionId}/brief?agentId=agent-b`,
322
          headers: {
323
            'x-openclasp-operator': 'user-a',
324
            'x-openclasp-bound-agent': 'agent-a',
325
          },
326
        })
327
      ).statusCode,
328
    ).toBe(403);
329
    const completionReport = {
330
      reportId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
331
      interactionId,
332
      contractHash: 'contract-hash',
333
      reportingAgentId: 'agent-a',
334
      counterpartyAgentId: 'agent-b',
335
      agentVersion: '1.0.0',
336
      outcome: 'partial',
337
      summary: 'The counterparty answered but no matching role was available.',
338
      requestedOutcome: 'Find a backend role',
339
      criteria: [],
340
      completedAt: '2026-08-29T00:00:00.000Z',
341
      confidence: 0.9,
342
      dataSharingMode: 'structured_only',
343
    };
344
    expect(
345
      (
346
        await app.inject({
347
          method: 'POST',
348
          url: `/v0.1/federated-interactions/${interactionId}/completion-reports`,
349
          headers: {
350
            'x-openclasp-operator': 'user-a',
351
            'x-openclasp-bound-agent': 'agent-a',
352
          },
353
          payload: completionReport,
354
        })
355
      ).statusCode,
356
    ).toBe(200);
357
    expect(calls.at(-1)).toBe(`completion:user-a:agent-a:${interactionId}:oauth_installation`);
358
    expect(
359
      (
360
        await app.inject({
361
          method: 'POST',
362
          url: `/v0.1/federated-interactions/${interactionId}/completion-reports`,
363
          headers: {
364
            'x-openclasp-operator': 'user-a',
365
            'x-openclasp-bound-agent': 'agent-a',
366
          },
367
          payload: { ...completionReport, rawTranscript: 'must never be accepted' },
368
        })
369
      ).statusCode,
370
    ).toBe(400);
371
    expect(
372
      (
373
        await app.inject({
374
          method: 'POST',
375
          url: `/sessions/${interactionId}/completion-reports`,
376
          payload: completionReport,
377
        })
378
      ).statusCode,
379
    ).toBe(401);
380
    expect(
381
      (
382
        await app.inject({
383
          method: 'POST',
384
          url: `/sessions/${interactionId}/completion-reports`,
385
          headers: { authorization: 'Bearer scoped-session-token' },
386
          payload: completionReport,
387
        })
388
      ).statusCode,
389
    ).toBe(200);
390
    expect(calls.at(-1)).toBe(`session-completion:scoped-session-token:${interactionId}`);
391
    const providerConnection = await app.inject({
392
      method: 'POST',
393
      url: '/v0.1/provider-connections',
394
      headers: { 'x-openclasp-operator': 'user-a' },
395
      payload: {
396
        provider: 'botpress',
397
        agentName: 'Recruiting agent',
398
        projectName: 'Recruiting',
399
        capabilities: ['candidate matching'],
400
        limitations: ['no final hiring decisions'],
401
        expiresInDays: 365,
402
      },
403
    });
404
    expect(providerConnection.statusCode).toBe(200);
405
    expect(providerConnection.json()).toMatchObject({
406
      agent: {
407
        name: 'Recruiting agent',
408
        framework: 'Botpress',
409
        identityMode: 'owner_managed',
410
      },
411
      accessToken: { name: 'Botpress', scopes: ['mcp:access', 'runtime:connect'] },
412
    });
413
    expect(calls.at(-1)).toMatch(/^issue-token:user-a:agent_.*:Botpress:365$/);
414
    expect(
415
      (
416
        await app.inject({
417
          method: 'POST',
418
          url: '/a2a/temporary/agent-a',
419
          payload: {
420
            jsonrpc: '2.0',
421
            id: 'request-1',
422
            method: 'message/send',
423
            params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
424
          },
425
        })
426
      ).statusCode,
427
    ).toBe(401);
428
    const temporaryDelivery = await app.inject({
429
      method: 'POST',
430
      url: '/a2a/temporary/agent-a',
431
      headers: { authorization: 'Bearer scoped-token' },
432
      payload: {
433
        jsonrpc: '2.0',
434
        id: 'request-1',
435
        method: 'message/send',
436
        params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
437
      },
438
    });
439
    expect(temporaryDelivery.statusCode).toBe(200);
440
    expect(temporaryDelivery.json()).toMatchObject({
441
      result: {
442
        task: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', state: 'submitted' },
443
        privacyMode: 'openclasp_hosted_temporary',
444
      },
445
    });
446
    expect(calls.at(-1)).toBe('temporary:scoped-token:agent-a:request-1:Hello engineer');
447
    await app.close();
448
  });
449
450
  it('publishes only an owned agent public card', async () => {
451
    const published: any[] = [];
452
    const repository = {
453
      dashboard: async () => ({}) as any,
454
      getSettings: async () => ({}) as any,
455
      saveSettings: async () => ({}) as any,
456
      upsert: async () => undefined,
457
      list: async () => [
458
        {
459
          kind: 'agent_profile' as const,
460
          recordId: 'agent-one',
461
          payload: {
462
            agentId: 'agent-one',
463
            projectId: 'secret-project',
464
            name: 'Research agent',
465
            description: 'Finds primary sources',
466
            framework: 'Codex',
467
            agentVersion: '1.0.0',
468
            a2aEndpoint: 'https://agent.example/a2a',
469
            capabilities: ['research'],
470
            limitations: ['no purchases'],
471
            identityMode: 'oauth_installation' as const,
472
            status: 'active' as const,
473
            createdAt: '2026-01-01T00:00:00.000Z',
474
            updatedAt: '2026-01-01T00:00:00.000Z',
475
          },
476
        },
477
      ],
478
      publishAgent: async (_operatorId: string, card: any) => {
479
        published.push(card);
480
        return card;
481
      },
482
      unpublishAgent: async () => true,
483
      getPublishedAgent: async () => published[0],
484
      searchPublishedAgents: async () => published,
485
    };
486
    const app = buildApi(undefined, undefined, repository);
487
    await app.ready();
488
    const response = await app.inject({
489
      method: 'POST',
490
      url: '/v0.1/agents/agent-one/publication',
491
      headers: { 'x-openclasp-operator': 'owner-one' },
492
      payload: { published: true },
493
    });
494
    expect(response.statusCode).toBe(200);
495
    expect(published[0]).toMatchObject({
496
      agentId: 'agent-one',
497
      capabilities: ['research'],
498
      assurance: 'oauth_authenticated',
499
    });
500
    expect(published[0]).not.toHaveProperty('projectId');
501
    expect(published[0]).not.toHaveProperty('operatorId');
502
    const publicCard = await app.inject({
503
      method: 'GET',
504
      url: '/agents/agent-one/card.json',
505
    });
506
    expect(publicCard.statusCode).toBe(200);
507
    expect(publicCard.json()).not.toHaveProperty('projectId');
508
    const a2aCard = await app.inject({
509
      method: 'GET',
510
      url: '/agents/agent-one/a2a-agent-card.json',
511
    });
512
    expect(a2aCard.statusCode).toBe(200);
513
    expect(a2aCard.json().supportedInterfaces[0]).toMatchObject({
514
      url: 'https://agent.example/a2a',
515
      protocolVersion: '1.0',
516
    });
517
    const automation = await app.inject({
518
      method: 'PUT',
519
      url: '/v0.1/agents/agent-one/automation',
520
      headers: { 'x-openclasp-operator': 'owner-one' },
521
      payload: {
522
        a2aEndpoint: 'https://agent.example/a2a',
523
        autoPublish: true,
524
        autoAcceptPolicy: 'safe_matching',
525
        autoAcceptTaskCategories: ['research'],
526
      },
527
    });
528
    expect(automation.statusCode).toBe(200);
529
    expect(automation.json()).toMatchObject({
530
      autoPublish: true,
531
      autoAcceptPolicy: 'safe_matching',
532
    });
533
    await app.close();
534
  });
535
536
  it('accepts only structured live-session events at the reporting endpoint', async () => {
537
    const events: any[] = [];
538
    const repository = {
539
      dashboard: async () => ({}) as any,
540
      getSettings: async () => ({}) as any,
541
      saveSettings: async () => ({}) as any,
542
      upsert: async () => undefined,
543
      list: async () => [],
544
      publishAgent: async (_operatorId: string, card: any) => card,
545
      unpublishAgent: async () => true,
546
      getPublishedAgent: async () => undefined,
547
      searchPublishedAgents: async () => [],
548
      recordLiveSessionEvent: async (token: string, value: any) => {
549
        events.push({ token, value });
550
        return {
551
          recorded: true,
552
          deduplicated: false,
553
          eventId: value.eventId,
554
          attestation: {
555
            algorithm: 'Ed25519' as const,
556
            keyId: 'openclasp:test',
557
            value: 'signature',
558
            digest: 'digest',
559
          },
560
        };
561
      },
562
    };
563
    const app = buildApi(undefined, undefined, repository);
564
    await app.ready();
565
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
566
    expect(
567
      (await app.inject({ method: 'POST', url: `/sessions/${interactionId}/events` })).statusCode,
568
    ).toBe(401);
569
    const response = await app.inject({
570
      method: 'POST',
571
      url: `/sessions/${interactionId}/events`,
572
      headers: { authorization: 'Bearer live-session-token' },
573
      payload: {
574
        eventId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
575
        interactionId,
576
        agentId: 'agent-a',
577
        sequence: 1,
578
        type: 'message_sent',
579
        occurredAt: new Date().toISOString(),
580
        messageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
581
        evidenceReferences: [],
582
        details: {},
583
      },
584
    });
585
    expect(response.statusCode).toBe(200);
586
    expect(events[0]).toMatchObject({
587
      token: 'live-session-token',
588
      value: { interactionId, agentId: 'agent-a', type: 'message_sent' },
589
    });
590
    const rawMessageAttempt = await app.inject({
591
      method: 'POST',
592
      url: `/sessions/${interactionId}/events`,
593
      headers: { authorization: 'Bearer live-session-token' },
594
      payload: {
595
        eventId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
596
        interactionId,
597
        agentId: 'agent-a',
598
        sequence: 2,
599
        type: 'message_sent',
600
        occurredAt: new Date().toISOString(),
601
        details: { message: 'raw conversation text' },
602
      },
603
    });
604
    expect(rawMessageAttempt.statusCode).toBe(400);
605
    expect(events).toHaveLength(1);
606
    await app.close();
607
  });
608
});