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

Viewing files at commit 81afd7789b27Commit detailsView latest

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