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

Viewing files at commit 89b58109a0e6Commit detailsView latest

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