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

Viewing files at commit 922a6b480fb8Commit detailsView latest

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