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

Viewing files at commit 28cda791c50eCommit detailsView latest

tests/api.test.ts 934 lines · 32.1 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
    let storedProfile: any = {
24
      agentId: 'agent-a',
25
      projectId: 'project-a',
26
      name: 'Test agent',
27
      description: '',
28
      framework: 'Botpress',
29
      agentVersion: '1.0.0',
30
      agentMode: 'persistent_runtime',
31
      transport: 'direct_a2a',
32
      autoPublish: false,
33
      autoAcceptPolicy: 'off',
34
      autoAcceptTaskCategories: [],
35
      capabilities: [],
36
      limitations: [],
37
      identityMode: 'owner_managed',
38
      status: 'active',
39
      createdAt: '2026-01-01T00:00:00.000Z',
40
      updatedAt: '2026-01-01T00:00:00.000Z',
41
    };
42
    const repository = {
43
      dashboard: async (operatorId: string) => {
44
        calls.push(`dashboard:${operatorId}`);
45
        return {
46
          agents: [],
47
          projects: [],
48
          installations: [],
49
          setupRequests: [],
50
          publications: [],
51
          interactions: [],
52
          federatedInteractions: [],
53
          liveSessions: [],
54
          hostedThreads: [],
55
          events: [],
56
          conflicts: [],
57
          receipts: [],
58
          profiles: [],
59
          counterpartyBriefs: [],
60
          completionReports: [],
61
          feedbackRequests: [],
62
          interactionFeedback: [],
63
          interactionConclusions: [],
64
          learningEligibility: [],
65
          profileDeltas: [],
66
          runtimes: [],
67
          accessTokens: [],
68
        };
69
      },
70
      getSettings: async (operatorId: string) => {
71
        calls.push(`settings:${operatorId}`);
72
        return {
73
          displayName: '',
74
          contributionEnabled: false,
75
          retentionDays: 30,
76
          evidenceSharing: 'ask' as const,
77
          rawConversationsStored: false as const,
78
        };
79
      },
80
      saveSettings: async (operatorId: string, value: any) => {
81
        calls.push(`save:${operatorId}`);
82
        return { ...value, rawConversationsStored: false as const };
83
      },
84
      upsert: async (_operatorId: string, kind: string, _recordId: string, payload: any) => {
85
        if (kind === 'agent_profile') storedProfile = payload;
86
      },
87
      list: async () => [
88
        { kind: 'agent_profile' as const, recordId: 'agent-a', payload: storedProfile },
89
        {
90
          kind: 'publication' as const,
91
          recordId: 'agent-a',
92
          payload: { agentId: 'agent-a', published: true },
93
        },
94
      ],
95
      publishAgent: async (_operatorId: string, card: any) => card,
96
      unpublishAgent: async () => true,
97
      getPublishedAgent: async () => undefined,
98
      resolveAgentReference: async () => undefined,
99
      searchPublishedAgents: async () => [],
100
      listContextualIntelligence: async () => [
101
        {
102
          agentId: 'agent-peer',
103
          agentVersion: '1.0.0',
104
          taskCategory: 'research',
105
          score: 0.8,
106
          confidence: {
107
            level: 'medium' as const,
108
            value: 0.5,
109
            evidenceCount: 4,
110
            effectiveSampleSize: 3,
111
          },
112
          trend: { direction: 'stable' as const, delta: 0 },
113
          strengths: [],
114
          risks: [],
115
          versionStatus: {
116
            currentVersion: '1.0.0',
117
            evidenceVersion: '1.0.0',
118
            status: 'current' as const,
119
          },
120
          source: 'private_verified_history' as const,
121
          updatedAt: '2026-08-30T00:00:00.000Z',
122
        },
123
      ],
124
      searchPersonalizedMarketplace: async () => [
125
        {
126
          card: {
127
            agentId: 'agent-peer',
128
            name: 'Peer',
129
            agentMode: 'persistent_runtime' as const,
130
            transports: [{ managedBy: 'agent' as const }],
131
          },
132
          taskCategory: 'research',
133
          match: { score: 0.7, label: 'possible', reasons: [] },
134
        },
135
        {
136
          card: {
137
            agentId: 'temporary-peer',
138
            name: 'Temporary peer',
139
            agentMode: 'temporary_chat' as const,
140
            transports: [{ managedBy: 'openclasp' as const }],
141
          },
142
          taskCategory: 'research',
143
          match: { score: 0.9, label: 'strong', reasons: [] },
144
        },
145
      ],
146
      registerAgentRuntime: async (operatorId: string, agentId: string, endpoint: string) => {
147
        calls.push(`runtime:${operatorId}:${agentId}:${endpoint}`);
148
        return {
149
          agentId,
150
          endpoint,
151
          a2aEndpoint: endpoint,
152
          status: 'verified' as const,
153
          verifiedAt: new Date().toISOString(),
154
          verificationKey: 'public-key',
155
        };
156
      },
157
      touchAgentPresence: async (operatorId: string, agentId: string) => {
158
        calls.push(`heartbeat:${operatorId}:${agentId}`);
159
        return {
160
          status: 'online' as const,
161
          checkedAt: '2026-01-01T00:00:00.000Z',
162
        };
163
      },
164
      disableAgentRuntime: async (operatorId: string, agentId: string) => {
165
        calls.push(`disable-runtime:${operatorId}:${agentId}`);
166
        return { agentId, status: 'disabled' as const };
167
      },
168
      deleteAgent: async (operatorId: string, agentId: string) => {
169
        calls.push(`delete-agent:${operatorId}:${agentId}`);
170
        return { agentId, deleted: true as const, historyRetained: true as const };
171
      },
172
      listAgentAccessTokens: async (operatorId: string, agentId?: string) => {
173
        calls.push(`list-tokens:${operatorId}:${agentId ?? '*'}`);
174
        return [];
175
      },
176
      issueAgentAccessToken: async (
177
        operatorId: string,
178
        agentId: string,
179
        value: { name: string; expiresInDays: number },
180
      ) => {
181
        calls.push(`issue-token:${operatorId}:${agentId}:${value.name}:${value.expiresInDays}`);
182
        return {
183
          tokenId: 'abcdefghijklmnop',
184
          token: 'oc_at_abcdefghijklmnop.abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
185
          agentId,
186
          name: value.name,
187
          scopes: ['mcp:access', 'runtime:connect'],
188
          createdAt: '2026-01-01T00:00:00.000Z',
189
          expiresAt: '2027-01-01T00:00:00.000Z',
190
        };
191
      },
192
      revokeAgentAccessToken: async (operatorId: string, agentId: string, tokenId: string) => {
193
        calls.push(`revoke-token:${operatorId}:${agentId}:${tokenId}`);
194
        return { tokenId, agentId, revokedAt: '2026-01-02T00:00:00.000Z' };
195
      },
196
      getCounterpartyBrief: async (operatorId: string, interactionId: string, agentId: string) => {
197
        calls.push(`brief:${operatorId}:${interactionId}:${agentId}`);
198
        return {
199
          briefId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
200
          interactionId,
201
          contractHash: 'contract-hash',
202
          recipientAgentId: agentId,
203
          subjectAgentId: 'agent-b',
204
          taskCategory: 'recruiting',
205
          decision: 'CHALLENGE' as const,
206
          requirements: [],
207
          insights: [],
208
          relevantSampleSize: 0,
209
          historyConfidence: 0,
210
          subjectAgentVersion: '1.0.0',
211
          recommendedContractChanges: [],
212
          generatedAt: '2026-08-29T00:00:00.000Z',
213
          expiresAt: '2026-08-29T01:00:00.000Z',
214
        };
215
      },
216
      submitCompletionReport: async (
217
        operatorId: string,
218
        agentId: string,
219
        report: any,
220
        submissionMethod:
221
          'oauth_account' | 'oauth_installation' | 'agent_access_token' | 'runtime_session',
222
      ) => {
223
        calls.push(
224
          `completion:${operatorId}:${agentId}:${report.interactionId}:${submissionMethod}`,
225
        );
226
        return report;
227
      },
228
      recordSessionCompletionReport: async (token: string, report: any) => {
229
        calls.push(`session-completion:${token}:${report.interactionId}`);
230
        return report;
231
      },
232
      listFeedbackRequests: async (operatorId: string, agentId: string) => {
233
        calls.push(`feedback-requests:${operatorId}:${agentId}`);
234
        return [];
235
      },
236
      submitInteractionFeedback: async (
237
        operatorId: string,
238
        agentId: string,
239
        feedback: any,
240
        submissionMethod:
241
          'oauth_account' | 'oauth_installation' | 'agent_access_token' | 'runtime_session',
242
      ) => {
243
        calls.push(
244
          `feedback:${operatorId}:${agentId}:${feedback.interactionId}:${submissionMethod}`,
245
        );
246
        return { feedbackId: feedback.feedbackId, status: 'submitted' as const, revealed: false };
247
      },
248
      recordSessionFeedback: async (token: string, feedback: any) => {
249
        calls.push(`session-feedback:${token}:${feedback.interactionId}`);
250
        return { feedbackId: feedback.feedbackId, status: 'submitted' as const, revealed: false };
251
      },
252
      receiveTemporaryMessage: async (
253
        token: string,
254
        agentId: string,
255
        requestKey: string,
256
        content: string,
257
      ) => {
258
        calls.push(`temporary:${token}:${agentId}:${requestKey}:${content}`);
259
        return {
260
          message: {
261
            messageId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
262
            threadId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
263
            interactionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
264
            senderAgentId: 'agent-peer',
265
            recipientAgentId: agentId,
266
            contentType: 'text/plain' as const,
267
            content,
268
            contentHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
269
            delivery: 'delivered' as const,
270
            createdAt: new Date().toISOString(),
271
          },
272
          deduplicated: false,
273
        };
274
      },
275
    };
276
    const app = buildApi(undefined, undefined, repository);
277
    await app.ready();
278
    expect((await app.inject({ method: 'GET', url: '/v0.1/dashboard' })).statusCode).toBe(401);
279
    expect(
280
      (
281
        await app.inject({
282
          method: 'GET',
283
          url: '/v0.1/dashboard',
284
          headers: { 'x-openclasp-operator': 'user-a' },
285
        })
286
      ).statusCode,
287
    ).toBe(200);
288
    expect(
289
      (
290
        await app.inject({
291
          method: 'GET',
292
          url: '/v0.1/intelligence?agentId=agent-peer&taskCategory=research',
293
          headers: { 'x-openclasp-operator': 'user-a' },
294
        })
295
      ).json()[0],
296
    ).toMatchObject({ agentId: 'agent-peer', score: 0.8 });
297
    const marketplace = (
298
      await app.inject({
299
        method: 'GET',
300
        url: '/v0.1/marketplace?agentId=agent-a&taskCategory=research',
301
        headers: { 'x-openclasp-operator': 'user-a' },
302
      })
303
    ).json();
304
    expect(marketplace).toHaveLength(1);
305
    expect(marketplace[0]).toMatchObject({
306
      card: { agentId: 'agent-peer', agentMode: 'persistent_runtime' },
307
      match: { label: 'possible' },
308
    });
309
    expect(
310
      (
311
        await app.inject({
312
          method: 'GET',
313
          url: '/v0.1/settings',
314
          headers: { 'x-openclasp-operator': 'user-b' },
315
        })
316
      ).statusCode,
317
    ).toBe(200);
318
    expect(
319
      (
320
        await app.inject({
321
          method: 'PUT',
322
          url: '/v0.1/agents/agent-a/runtime',
323
          headers: { 'x-openclasp-operator': 'user-a' },
324
          payload: { endpoint: 'https://agent.example/openclasp' },
325
        })
326
      ).statusCode,
327
    ).toBe(200);
328
    expect(calls).toEqual([
329
      'dashboard:user-a',
330
      'settings:user-b',
331
      'runtime:user-a:agent-a:https://agent.example/openclasp',
332
    ]);
333
    expect(
334
      (
335
        await app.inject({
336
          method: 'GET',
337
          url: '/v0.1/runtime/bootstrap',
338
          headers: {
339
            'x-openclasp-operator': 'user-a',
340
            'x-openclasp-bound-agent': 'agent-a',
341
            host: 'openclasp.example',
342
            'x-forwarded-proto': 'https',
343
          },
344
        })
345
      ).json(),
346
    ).toMatchObject({
347
      agentId: 'agent-a',
348
      openClaspUrl: 'https://openclasp.example',
349
      protocol: 'A2A/1.0',
350
    });
351
    expect(
352
      (
353
        await app.inject({
354
          method: 'PUT',
355
          url: '/v0.1/runtime',
356
          headers: {
357
            'x-openclasp-operator': 'user-a',
358
            'x-openclasp-bound-agent': 'agent-a',
359
          },
360
          payload: { endpoint: 'https://runtime.example/a2a' },
361
        })
362
      ).statusCode,
363
    ).toBe(200);
364
    expect(calls.at(-1)).toBe('runtime:user-a:agent-a:https://runtime.example/a2a');
365
    expect(
366
      (
367
        await app.inject({
368
          method: 'DELETE',
369
          url: '/v0.1/runtime',
370
          headers: {
371
            'x-openclasp-operator': 'user-a',
372
            'x-openclasp-bound-agent': 'agent-a',
373
          },
374
        })
375
      ).statusCode,
376
    ).toBe(200);
377
    expect(calls.at(-1)).toBe('disable-runtime:user-a:agent-a');
378
    expect(
379
      (
380
        await app.inject({
381
          method: 'POST',
382
          url: '/v0.1/runtime/heartbeat',
383
          headers: {
384
            'x-openclasp-operator': 'user-a',
385
            'x-openclasp-bound-agent': 'agent-a',
386
          },
387
        })
388
      ).statusCode,
389
    ).toBe(200);
390
    expect(calls.at(-1)).toBe('heartbeat:user-a:agent-a');
391
    const runtimeProfile = await app.inject({
392
      method: 'PUT',
393
      url: '/v0.1/runtime/profile',
394
      headers: {
395
        'x-openclasp-operator': 'user-a',
396
        'x-openclasp-bound-agent': 'agent-a',
397
        host: 'openclasp.example',
398
        'x-forwarded-proto': 'https',
399
      },
400
      payload: {
401
        description: 'Finds suitable candidates for open roles',
402
        capabilities: ['recruiting', 'candidate-screening', 'recruiting'],
403
        limitations: ['no-live-job-database'],
404
      },
405
    });
406
    expect(runtimeProfile.statusCode).toBe(200);
407
    expect(runtimeProfile.json()).toMatchObject({
408
      profile: {
409
        capabilities: ['recruiting', 'candidate-screening'],
410
        limitations: ['no-live-job-database'],
411
      },
412
      card: { capabilities: ['recruiting', 'candidate-screening'] },
413
    });
414
    expect(storedProfile.description).toBe('Finds suitable candidates for open roles');
415
    expect(
416
      (
417
        await app.inject({
418
          method: 'DELETE',
419
          url: '/v0.1/agents/agent-a',
420
          headers: { 'x-openclasp-operator': 'user-a' },
421
        })
422
      ).json(),
423
    ).toMatchObject({ agentId: 'agent-a', deleted: true, historyRetained: true });
424
    expect(calls.at(-1)).toBe('delete-agent:user-a:agent-a');
425
    const issuedToken = await app.inject({
426
      method: 'POST',
427
      url: '/v0.1/agents/agent-a/access-tokens',
428
      headers: { 'x-openclasp-operator': 'user-a' },
429
      payload: { name: 'Botpress', expiresInDays: 365 },
430
    });
431
    expect(issuedToken.statusCode).toBe(200);
432
    expect(issuedToken.json()).toMatchObject({
433
      agentId: 'agent-a',
434
      name: 'Botpress',
435
      scopes: ['mcp:access', 'runtime:connect'],
436
    });
437
    expect(calls.at(-1)).toBe('issue-token:user-a:agent-a:Botpress:365');
438
    expect(
439
      (
440
        await app.inject({
441
          method: 'DELETE',
442
          url: '/v0.1/agents/agent-a/access-tokens/abcdefghijklmnop',
443
          headers: { 'x-openclasp-operator': 'user-a' },
444
        })
445
      ).statusCode,
446
    ).toBe(200);
447
    expect(calls.at(-1)).toBe('revoke-token:user-a:agent-a:abcdefghijklmnop');
448
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
449
    expect(
450
      (
451
        await app.inject({
452
          method: 'GET',
453
          url: `/v0.1/federated-interactions/${interactionId}/brief?agentId=agent-a`,
454
          headers: { 'x-openclasp-operator': 'user-a' },
455
        })
456
      ).json(),
457
    ).toMatchObject({ recipientAgentId: 'agent-a', decision: 'CHALLENGE' });
458
    expect(
459
      (
460
        await app.inject({
461
          method: 'GET',
462
          url: `/v0.1/federated-interactions/${interactionId}/brief?agentId=agent-b`,
463
          headers: {
464
            'x-openclasp-operator': 'user-a',
465
            'x-openclasp-bound-agent': 'agent-a',
466
          },
467
        })
468
      ).statusCode,
469
    ).toBe(403);
470
    const completionReport = {
471
      reportId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
472
      interactionId,
473
      contractHash: 'contract-hash',
474
      reportingAgentId: 'agent-a',
475
      counterpartyAgentId: 'agent-b',
476
      agentVersion: '1.0.0',
477
      outcome: 'partial',
478
      summary: 'The counterparty answered but no matching role was available.',
479
      requestedOutcome: 'Find a backend role',
480
      criteria: [],
481
      completedAt: '2026-08-29T00:00:00.000Z',
482
      confidence: 0.9,
483
      dataSharingMode: 'structured_only',
484
    };
485
    expect(
486
      (
487
        await app.inject({
488
          method: 'POST',
489
          url: `/v0.1/federated-interactions/${interactionId}/completion-reports`,
490
          headers: {
491
            'x-openclasp-operator': 'user-a',
492
            'x-openclasp-bound-agent': 'agent-a',
493
          },
494
          payload: completionReport,
495
        })
496
      ).statusCode,
497
    ).toBe(200);
498
    expect(calls.at(-1)).toBe(`completion:user-a:agent-a:${interactionId}:oauth_installation`);
499
    expect(
500
      (
501
        await app.inject({
502
          method: 'POST',
503
          url: `/v0.1/federated-interactions/${interactionId}/completion-reports`,
504
          headers: {
505
            'x-openclasp-operator': 'user-a',
506
            'x-openclasp-bound-agent': 'agent-a',
507
          },
508
          payload: { ...completionReport, rawTranscript: 'must never be accepted' },
509
        })
510
      ).statusCode,
511
    ).toBe(400);
512
    expect(
513
      (
514
        await app.inject({
515
          method: 'POST',
516
          url: `/sessions/${interactionId}/completion-reports`,
517
          payload: completionReport,
518
        })
519
      ).statusCode,
520
    ).toBe(401);
521
    expect(
522
      (
523
        await app.inject({
524
          method: 'POST',
525
          url: `/sessions/${interactionId}/completion-reports`,
526
          headers: { authorization: 'Bearer scoped-session-token' },
527
          payload: completionReport,
528
        })
529
      ).statusCode,
530
    ).toBe(200);
531
    expect(calls.at(-1)).toBe(`session-completion:scoped-session-token:${interactionId}`);
532
    expect(
533
      (
534
        await app.inject({
535
          method: 'GET',
536
          url: '/v0.1/feedback-requests?agentId=agent-a',
537
          headers: {
538
            'x-openclasp-operator': 'user-a',
539
            'x-openclasp-bound-agent': 'agent-a',
540
          },
541
        })
542
      ).statusCode,
543
    ).toBe(200);
544
    const interactionFeedback = {
545
      feedbackId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
546
      requestId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
547
      interactionId,
548
      reviewerAgentId: 'agent-a',
549
      subjectAgentId: 'agent-b',
550
      reviewerAgentVersion: '1.0.0',
551
      ratings: {
552
        overall_satisfaction: 0.7,
553
        outcome_satisfaction: 0.6,
554
        communication: 0.9,
555
        timeliness: 0.8,
556
        scope_adherence: 0.6,
557
        evidence_quality: 0.7,
558
        correction_handling: 0.8,
559
        reliability: 0.75,
560
      },
561
      wouldWorkAgain: 'yes',
562
      confidence: 0.9,
563
      submittedAt: '2026-08-29T00:10:00.000Z',
564
    };
565
    expect(
566
      (
567
        await app.inject({
568
          method: 'POST',
569
          url: `/v0.1/federated-interactions/${interactionId}/feedback`,
570
          headers: {
571
            'x-openclasp-operator': 'user-a',
572
            'x-openclasp-bound-agent': 'agent-a',
573
          },
574
          payload: interactionFeedback,
575
        })
576
      ).statusCode,
577
    ).toBe(200);
578
    expect(calls.at(-1)).toBe(`feedback:user-a:agent-a:${interactionId}:oauth_installation`);
579
    expect(
580
      (
581
        await app.inject({
582
          method: 'POST',
583
          url: `/sessions/${interactionId}/feedback`,
584
          headers: { authorization: 'Bearer scoped-session-token' },
585
          payload: interactionFeedback,
586
        })
587
      ).statusCode,
588
    ).toBe(200);
589
    expect(calls.at(-1)).toBe(`session-feedback:scoped-session-token:${interactionId}`);
590
    const providerConnection = await app.inject({
591
      method: 'POST',
592
      url: '/v0.1/provider-connections',
593
      headers: { 'x-openclasp-operator': 'user-a' },
594
      payload: {
595
        provider: 'botpress',
596
        agentName: 'Recruiting agent',
597
        projectName: 'Recruiting',
598
        description: 'Matches candidates with open roles',
599
        capabilities: ['candidate matching'],
600
        limitations: ['no final hiring decisions'],
601
        expiresInDays: 365,
602
      },
603
    });
604
    expect(providerConnection.statusCode).toBe(200);
605
    expect(providerConnection.json()).toMatchObject({
606
      agent: {
607
        name: 'Recruiting agent',
608
        framework: 'Botpress',
609
        identityMode: 'owner_managed',
610
      },
611
      accessToken: { name: 'Botpress', scopes: ['mcp:access', 'runtime:connect'] },
612
    });
613
    expect(calls.at(-1)).toMatch(/^issue-token:user-a:agent_.*:Botpress:365$/);
614
    expect(
615
      (
616
        await app.inject({
617
          method: 'POST',
618
          url: '/a2a/temporary/agent-a',
619
          payload: {
620
            jsonrpc: '2.0',
621
            id: 'request-1',
622
            method: 'message/send',
623
            params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
624
          },
625
        })
626
      ).statusCode,
627
    ).toBe(401);
628
    const temporaryDelivery = await app.inject({
629
      method: 'POST',
630
      url: '/a2a/temporary/agent-a',
631
      headers: { authorization: 'Bearer scoped-token' },
632
      payload: {
633
        jsonrpc: '2.0',
634
        id: 'request-1',
635
        method: 'message/send',
636
        params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
637
      },
638
    });
639
    expect(temporaryDelivery.statusCode).toBe(200);
640
    expect(temporaryDelivery.json()).toMatchObject({
641
      result: {
642
        task: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', state: 'submitted' },
643
        privacyMode: 'openclasp_hosted_temporary',
644
      },
645
    });
646
    expect(calls.at(-1)).toBe('temporary:scoped-token:agent-a:request-1:Hello engineer');
647
    await app.close();
648
  });
649
650
  it('publishes only an owned agent public card', async () => {
651
    const published: any[] = [];
652
    const repository = {
653
      dashboard: async () => ({}) as any,
654
      getSettings: async () => ({}) as any,
655
      saveSettings: async () => ({}) as any,
656
      upsert: async () => undefined,
657
      list: async () => [
658
        {
659
          kind: 'agent_profile' as const,
660
          recordId: 'agent-one',
661
          payload: {
662
            agentId: 'agent-one',
663
            projectId: 'secret-project',
664
            name: 'Research agent',
665
            description: 'Finds primary sources',
666
            framework: 'Codex',
667
            agentVersion: '1.0.0',
668
            a2aEndpoint: 'https://agent.example/a2a',
669
            capabilities: ['research'],
670
            limitations: ['no purchases'],
671
            identityMode: 'oauth_installation' as const,
672
            status: 'active' as const,
673
            createdAt: '2026-01-01T00:00:00.000Z',
674
            updatedAt: '2026-01-01T00:00:00.000Z',
675
          },
676
        },
677
      ],
678
      publishAgent: async (_operatorId: string, card: any) => {
679
        published.push(card);
680
        return card;
681
      },
682
      unpublishAgent: async () => true,
683
      getPublishedAgent: async () => published[0],
684
      resolveAgentReference: async (reference: string) =>
685
        published[0]
686
          ? {
687
              reference,
688
              matchedBy: 'slug' as const,
689
              verified: true as const,
690
              card: published[0],
691
              resolvedAt: '2026-08-30T00:00:00.000Z',
692
            }
693
          : undefined,
694
      searchPublishedAgents: async () => published,
695
    };
696
    const app = buildApi(undefined, undefined, repository);
697
    await app.ready();
698
    const response = await app.inject({
699
      method: 'POST',
700
      url: '/v0.1/agents/agent-one/publication',
701
      headers: { 'x-openclasp-operator': 'owner-one' },
702
      payload: { published: true },
703
    });
704
    expect(response.statusCode).toBe(200);
705
    expect(published[0]).toMatchObject({
706
      agentId: 'agent-one',
707
      capabilities: ['research'],
708
      assurance: 'oauth_authenticated',
709
    });
710
    expect(published[0]).not.toHaveProperty('projectId');
711
    expect(published[0]).not.toHaveProperty('operatorId');
712
    const publicCard = await app.inject({
713
      method: 'GET',
714
      url: '/agents/agent-one/card.json',
715
    });
716
    expect(publicCard.statusCode).toBe(200);
717
    expect(publicCard.json()).not.toHaveProperty('projectId');
718
    const a2aCard = await app.inject({
719
      method: 'GET',
720
      url: '/agents/agent-one/a2a-agent-card.json',
721
    });
722
    expect(a2aCard.statusCode).toBe(200);
723
    expect(a2aCard.json().supportedInterfaces[0]).toMatchObject({
724
      url: 'https://agent.example/a2a',
725
      protocolVersion: '1.0',
726
    });
727
    const resolution = await app.inject({
728
      method: 'GET',
729
      url: `/directory/resolve?reference=${encodeURIComponent(published[0].profileUrl)}`,
730
    });
731
    expect(resolution.statusCode).toBe(200);
732
    expect(resolution.json()).toMatchObject({ verified: true, card: { agentId: 'agent-one' } });
733
    const profile = await app.inject({ method: 'GET', url: `/a/${published[0].slug}` });
734
    expect(profile.statusCode).toBe(200);
735
    expect(profile.headers['content-type']).toContain('text/html');
736
    expect(profile.body).toContain('Publisher verified');
737
    expect(profile.body).toContain('Share-ready Agent Card');
738
    expect(profile.body).toContain('Add your agent — free');
739
    expect(profile.body).toContain('Capabilities and limitations are declared by the publisher');
740
    expect(profile.body).toContain('property="og:image"');
741
    expect(profile.body).toContain('/agents/agent-one/og.png');
742
    expect(profile.body).toContain('name="twitter:card" content="summary_large_image"');
743
    const socialCard = await app.inject({ method: 'GET', url: '/agents/agent-one/og.png' });
744
    expect(socialCard.statusCode).toBe(200);
745
    expect(socialCard.headers['content-type']).toContain('image/png');
746
    expect(socialCard.rawPayload.subarray(1, 4).toString()).toBe('PNG');
747
    const automation = await app.inject({
748
      method: 'PUT',
749
      url: '/v0.1/agents/agent-one/automation',
750
      headers: { 'x-openclasp-operator': 'owner-one' },
751
      payload: {
752
        a2aEndpoint: 'https://agent.example/a2a',
753
        autoPublish: true,
754
        autoAcceptPolicy: 'safe_matching',
755
        autoAcceptTaskCategories: ['research'],
756
      },
757
    });
758
    expect(automation.statusCode).toBe(200);
759
    expect(automation.json()).toMatchObject({
760
      autoPublish: true,
761
      autoAcceptPolicy: 'safe_matching',
762
    });
763
    await app.close();
764
  });
765
766
  it('accepts only structured live-session events at the reporting endpoint', async () => {
767
    const events: any[] = [];
768
    const repository = {
769
      dashboard: async () => ({}) as any,
770
      getSettings: async () => ({}) as any,
771
      saveSettings: async () => ({}) as any,
772
      upsert: async () => undefined,
773
      list: async () => [],
774
      publishAgent: async (_operatorId: string, card: any) => card,
775
      unpublishAgent: async () => true,
776
      getPublishedAgent: async () => undefined,
777
      resolveAgentReference: async () => undefined,
778
      searchPublishedAgents: async () => [],
779
      recordLiveSessionEvent: async (token: string, value: any) => {
780
        events.push({ token, value });
781
        return {
782
          recorded: true,
783
          deduplicated: false,
784
          eventId: value.eventId,
785
          attestation: {
786
            algorithm: 'Ed25519' as const,
787
            keyId: 'openclasp:test',
788
            value: 'signature',
789
            digest: 'digest',
790
          },
791
        };
792
      },
793
    };
794
    const app = buildApi(undefined, undefined, repository);
795
    await app.ready();
796
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
797
    expect(
798
      (await app.inject({ method: 'POST', url: `/sessions/${interactionId}/events` })).statusCode,
799
    ).toBe(401);
800
    const response = await app.inject({
801
      method: 'POST',
802
      url: `/sessions/${interactionId}/events`,
803
      headers: { authorization: 'Bearer live-session-token' },
804
      payload: {
805
        eventId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
806
        interactionId,
807
        agentId: 'agent-a',
808
        sequence: 1,
809
        type: 'message_sent',
810
        occurredAt: new Date().toISOString(),
811
        messageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
812
        evidenceReferences: [],
813
        details: {},
814
      },
815
    });
816
    expect(response.statusCode).toBe(200);
817
    expect(events[0]).toMatchObject({
818
      token: 'live-session-token',
819
      value: { interactionId, agentId: 'agent-a', type: 'message_sent' },
820
    });
821
    const rawMessageAttempt = await app.inject({
822
      method: 'POST',
823
      url: `/sessions/${interactionId}/events`,
824
      headers: { authorization: 'Bearer live-session-token' },
825
      payload: {
826
        eventId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
827
        interactionId,
828
        agentId: 'agent-a',
829
        sequence: 2,
830
        type: 'message_sent',
831
        occurredAt: new Date().toISOString(),
832
        details: { message: 'raw conversation text' },
833
      },
834
    });
835
    expect(rawMessageAttempt.statusCode).toBe(400);
836
    expect(events).toHaveLength(1);
837
    await app.close();
838
  });
839
840
  it('binds contract proposals and responses to the authenticated participating agent', async () => {
841
    const calls: any[] = [];
842
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
843
    const revisionId = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
844
    const contract = {
845
      protocolVersion: '0.1',
846
      interactionId,
847
      purpose: 'Buy paper',
848
      parties: ['agent-a', 'agent-b'],
849
      taskCategory: 'procurement',
850
      requestedOutcome: 'Five tonnes delivered',
851
      successCriteria: ['80 GSM verified', 'Delivered on time'],
852
      allowedActions: ['negotiate'],
853
      prohibitedActions: ['exceed_budget'],
854
      allowedData: [],
855
      prohibitedData: ['credentials'],
856
      evidenceRequirements: ['invoice'],
857
      delegationRules: ['explicit_contract_scope'],
858
      humanApprovalRequirements: [],
859
      factCheckingPolicy: 'important_claims',
860
      mediationPolicy: 'mutual_consent',
861
      retentionDays: 30,
862
      completionConditions: ['delivery accepted'],
863
      cancellationConditions: ['either party before acceptance'],
864
      signatures: {},
865
    };
866
    const repository: any = {
867
      dashboard: async () => ({}),
868
      getSettings: async () => ({}),
869
      saveSettings: async () => ({}),
870
      upsert: async () => undefined,
871
      list: async () => [],
872
      publishAgent: async (_operatorId: string, card: any) => card,
873
      unpublishAgent: async () => true,
874
      getPublishedAgent: async () => undefined,
875
      resolveAgentReference: async () => undefined,
876
      searchPublishedAgents: async () => [],
877
      proposeContractRevision: async (...args: any[]) => {
878
        calls.push(['propose', ...args]);
879
        return { interactionId, status: 'pending' };
880
      },
881
      respondToContractRevision: async (...args: any[]) => {
882
        calls.push(['respond', ...args]);
883
        return { interactionId, status: 'active' };
884
      },
885
    };
886
    const app = buildApi(undefined, undefined, repository);
887
    await app.ready();
888
    const headers = {
889
      'x-openclasp-operator': 'owner-a',
890
      'x-openclasp-bound-agent': 'agent-a',
891
      'x-openclasp-credential-type': 'agent_access_token',
892
    };
893
    const proposed = await app.inject({
894
      method: 'POST',
895
      url: `/v0.1/federated-interactions/${interactionId}/contract-proposals`,
896
      headers,
897
      payload: { agentId: 'agent-a', expectedTermsHash: 'old-hash', contract },
898
    });
899
    expect(proposed.statusCode).toBe(200);
900
    expect(calls[0]).toMatchObject([
901
      'propose',
902
      'owner-a',
903
      interactionId,
904
      'agent-a',
905
      contract,
906
      'old-hash',
907
      'oauth_installation',
908
    ]);
909
    const accepted = await app.inject({
910
      method: 'POST',
911
      url: `/v0.1/federated-interactions/${interactionId}/contract-proposals/${revisionId}/respond`,
912
      headers,
913
      payload: { agentId: 'agent-a', decision: 'accept' },
914
    });
915
    expect(accepted.statusCode).toBe(200);
916
    expect(calls[1]).toEqual([
917
      'respond',
918
      'owner-a',
919
      interactionId,
920
      'agent-a',
921
      revisionId,
922
      'accept',
923
      'oauth_installation',
924
    ]);
925
    const impersonation = await app.inject({
926
      method: 'POST',
927
      url: `/v0.1/federated-interactions/${interactionId}/contract-proposals`,
928
      headers,
929
      payload: { agentId: 'agent-b', contract },
930
    });
931
    expect(impersonation.statusCode).toBe(403);
932
    await app.close();
933
  });
934
});