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

Viewing files at commit 2c749578ec22Commit detailsView latest

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