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

Viewing files at commit f87848a4fcb8Commit detailsView latest

tests/api.test.ts 489 lines · 16.5 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
    const repository = {
24
      dashboard: async (operatorId: string) => {
25
        calls.push(`dashboard:${operatorId}`);
26
        return {
27
          agents: [],
28
          projects: [],
29
          installations: [],
30
          setupRequests: [],
31
          publications: [],
32
          interactions: [],
33
          federatedInteractions: [],
34
          liveSessions: [],
35
          hostedThreads: [],
36
          events: [],
37
          conflicts: [],
38
          receipts: [],
39
          profiles: [],
40
          counterpartyBriefs: [],
41
          completionReports: [],
42
          feedbackRequests: [],
43
          interactionFeedback: [],
44
          interactionConclusions: [],
45
          learningEligibility: [],
46
          profileDeltas: [],
47
          runtimes: [],
48
          accessTokens: [],
49
        };
50
      },
51
      getSettings: async (operatorId: string) => {
52
        calls.push(`settings:${operatorId}`);
53
        return {
54
          displayName: '',
55
          contributionEnabled: false,
56
          retentionDays: 30,
57
          evidenceSharing: 'ask' as const,
58
          rawConversationsStored: false as const,
59
        };
60
      },
61
      saveSettings: async (operatorId: string, value: any) => {
62
        calls.push(`save:${operatorId}`);
63
        return { ...value, rawConversationsStored: false as const };
64
      },
65
      upsert: async () => undefined,
66
      list: async () => [],
67
      publishAgent: async (_operatorId: string, card: any) => card,
68
      unpublishAgent: async () => true,
69
      getPublishedAgent: async () => undefined,
70
      searchPublishedAgents: async () => [],
71
      registerAgentRuntime: async (operatorId: string, agentId: string, endpoint: string) => {
72
        calls.push(`runtime:${operatorId}:${agentId}:${endpoint}`);
73
        return {
74
          agentId,
75
          endpoint,
76
          a2aEndpoint: endpoint,
77
          status: 'verified' as const,
78
          verifiedAt: new Date().toISOString(),
79
          verificationKey: 'public-key',
80
        };
81
      },
82
      touchAgentPresence: async (operatorId: string, agentId: string) => {
83
        calls.push(`heartbeat:${operatorId}:${agentId}`);
84
        return {
85
          status: 'online' as const,
86
          checkedAt: '2026-01-01T00:00:00.000Z',
87
        };
88
      },
89
      disableAgentRuntime: async (operatorId: string, agentId: string) => {
90
        calls.push(`disable-runtime:${operatorId}:${agentId}`);
91
        return { agentId, status: 'disabled' as const };
92
      },
93
      deleteAgent: async (operatorId: string, agentId: string) => {
94
        calls.push(`delete-agent:${operatorId}:${agentId}`);
95
        return { agentId, deleted: true as const, historyRetained: true as const };
96
      },
97
      listAgentAccessTokens: async (operatorId: string, agentId?: string) => {
98
        calls.push(`list-tokens:${operatorId}:${agentId ?? '*'}`);
99
        return [];
100
      },
101
      issueAgentAccessToken: async (
102
        operatorId: string,
103
        agentId: string,
104
        value: { name: string; expiresInDays: number },
105
      ) => {
106
        calls.push(`issue-token:${operatorId}:${agentId}:${value.name}:${value.expiresInDays}`);
107
        return {
108
          tokenId: 'abcdefghijklmnop',
109
          token: 'oc_at_abcdefghijklmnop.abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG',
110
          agentId,
111
          name: value.name,
112
          scopes: ['mcp:access', 'runtime:connect'],
113
          createdAt: '2026-01-01T00:00:00.000Z',
114
          expiresAt: '2027-01-01T00:00:00.000Z',
115
        };
116
      },
117
      revokeAgentAccessToken: async (operatorId: string, agentId: string, tokenId: string) => {
118
        calls.push(`revoke-token:${operatorId}:${agentId}:${tokenId}`);
119
        return { tokenId, agentId, revokedAt: '2026-01-02T00:00:00.000Z' };
120
      },
121
      receiveTemporaryMessage: async (
122
        token: string,
123
        agentId: string,
124
        requestKey: string,
125
        content: string,
126
      ) => {
127
        calls.push(`temporary:${token}:${agentId}:${requestKey}:${content}`);
128
        return {
129
          message: {
130
            messageId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
131
            threadId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
132
            interactionId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
133
            senderAgentId: 'agent-peer',
134
            recipientAgentId: agentId,
135
            contentType: 'text/plain' as const,
136
            content,
137
            contentHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
138
            delivery: 'delivered' as const,
139
            createdAt: new Date().toISOString(),
140
          },
141
          deduplicated: false,
142
        };
143
      },
144
    };
145
    const app = buildApi(undefined, undefined, repository);
146
    await app.ready();
147
    expect((await app.inject({ method: 'GET', url: '/v0.1/dashboard' })).statusCode).toBe(401);
148
    expect(
149
      (
150
        await app.inject({
151
          method: 'GET',
152
          url: '/v0.1/dashboard',
153
          headers: { 'x-openclasp-operator': 'user-a' },
154
        })
155
      ).statusCode,
156
    ).toBe(200);
157
    expect(
158
      (
159
        await app.inject({
160
          method: 'GET',
161
          url: '/v0.1/settings',
162
          headers: { 'x-openclasp-operator': 'user-b' },
163
        })
164
      ).statusCode,
165
    ).toBe(200);
166
    expect(
167
      (
168
        await app.inject({
169
          method: 'PUT',
170
          url: '/v0.1/agents/agent-a/runtime',
171
          headers: { 'x-openclasp-operator': 'user-a' },
172
          payload: { endpoint: 'https://agent.example/openclasp' },
173
        })
174
      ).statusCode,
175
    ).toBe(200);
176
    expect(calls).toEqual([
177
      'dashboard:user-a',
178
      'settings:user-b',
179
      'runtime:user-a:agent-a:https://agent.example/openclasp',
180
    ]);
181
    expect(
182
      (
183
        await app.inject({
184
          method: 'GET',
185
          url: '/v0.1/runtime/bootstrap',
186
          headers: {
187
            'x-openclasp-operator': 'user-a',
188
            'x-openclasp-bound-agent': 'agent-a',
189
            host: 'openclasp.example',
190
            'x-forwarded-proto': 'https',
191
          },
192
        })
193
      ).json(),
194
    ).toMatchObject({
195
      agentId: 'agent-a',
196
      openClaspUrl: 'https://openclasp.example',
197
      protocol: 'A2A/1.0',
198
    });
199
    expect(
200
      (
201
        await app.inject({
202
          method: 'PUT',
203
          url: '/v0.1/runtime',
204
          headers: {
205
            'x-openclasp-operator': 'user-a',
206
            'x-openclasp-bound-agent': 'agent-a',
207
          },
208
          payload: { endpoint: 'https://runtime.example/a2a' },
209
        })
210
      ).statusCode,
211
    ).toBe(200);
212
    expect(calls.at(-1)).toBe('runtime:user-a:agent-a:https://runtime.example/a2a');
213
    expect(
214
      (
215
        await app.inject({
216
          method: 'DELETE',
217
          url: '/v0.1/runtime',
218
          headers: {
219
            'x-openclasp-operator': 'user-a',
220
            'x-openclasp-bound-agent': 'agent-a',
221
          },
222
        })
223
      ).statusCode,
224
    ).toBe(200);
225
    expect(calls.at(-1)).toBe('disable-runtime:user-a:agent-a');
226
    expect(
227
      (
228
        await app.inject({
229
          method: 'POST',
230
          url: '/v0.1/runtime/heartbeat',
231
          headers: {
232
            'x-openclasp-operator': 'user-a',
233
            'x-openclasp-bound-agent': 'agent-a',
234
          },
235
        })
236
      ).statusCode,
237
    ).toBe(200);
238
    expect(calls.at(-1)).toBe('heartbeat:user-a:agent-a');
239
    expect(
240
      (
241
        await app.inject({
242
          method: 'DELETE',
243
          url: '/v0.1/agents/agent-a',
244
          headers: { 'x-openclasp-operator': 'user-a' },
245
        })
246
      ).json(),
247
    ).toMatchObject({ agentId: 'agent-a', deleted: true, historyRetained: true });
248
    expect(calls.at(-1)).toBe('delete-agent:user-a:agent-a');
249
    const issuedToken = await app.inject({
250
      method: 'POST',
251
      url: '/v0.1/agents/agent-a/access-tokens',
252
      headers: { 'x-openclasp-operator': 'user-a' },
253
      payload: { name: 'Botpress', expiresInDays: 365 },
254
    });
255
    expect(issuedToken.statusCode).toBe(200);
256
    expect(issuedToken.json()).toMatchObject({
257
      agentId: 'agent-a',
258
      name: 'Botpress',
259
      scopes: ['mcp:access', 'runtime:connect'],
260
    });
261
    expect(calls.at(-1)).toBe('issue-token:user-a:agent-a:Botpress:365');
262
    expect(
263
      (
264
        await app.inject({
265
          method: 'DELETE',
266
          url: '/v0.1/agents/agent-a/access-tokens/abcdefghijklmnop',
267
          headers: { 'x-openclasp-operator': 'user-a' },
268
        })
269
      ).statusCode,
270
    ).toBe(200);
271
    expect(calls.at(-1)).toBe('revoke-token:user-a:agent-a:abcdefghijklmnop');
272
    const providerConnection = await app.inject({
273
      method: 'POST',
274
      url: '/v0.1/provider-connections',
275
      headers: { 'x-openclasp-operator': 'user-a' },
276
      payload: {
277
        provider: 'botpress',
278
        agentName: 'Recruiting agent',
279
        projectName: 'Recruiting',
280
        capabilities: ['candidate matching'],
281
        limitations: ['no final hiring decisions'],
282
        expiresInDays: 365,
283
      },
284
    });
285
    expect(providerConnection.statusCode).toBe(200);
286
    expect(providerConnection.json()).toMatchObject({
287
      agent: {
288
        name: 'Recruiting agent',
289
        framework: 'Botpress',
290
        identityMode: 'owner_managed',
291
      },
292
      accessToken: { name: 'Botpress', scopes: ['mcp:access', 'runtime:connect'] },
293
    });
294
    expect(calls.at(-1)).toMatch(/^issue-token:user-a:agent_.*:Botpress:365$/);
295
    expect(
296
      (
297
        await app.inject({
298
          method: 'POST',
299
          url: '/a2a/temporary/agent-a',
300
          payload: {
301
            jsonrpc: '2.0',
302
            id: 'request-1',
303
            method: 'message/send',
304
            params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
305
          },
306
        })
307
      ).statusCode,
308
    ).toBe(401);
309
    const temporaryDelivery = await app.inject({
310
      method: 'POST',
311
      url: '/a2a/temporary/agent-a',
312
      headers: { authorization: 'Bearer scoped-token' },
313
      payload: {
314
        jsonrpc: '2.0',
315
        id: 'request-1',
316
        method: 'message/send',
317
        params: { message: { parts: [{ kind: 'text', text: 'Hello engineer' }] } },
318
      },
319
    });
320
    expect(temporaryDelivery.statusCode).toBe(200);
321
    expect(temporaryDelivery.json()).toMatchObject({
322
      result: {
323
        task: { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', state: 'submitted' },
324
        privacyMode: 'openclasp_hosted_temporary',
325
      },
326
    });
327
    expect(calls.at(-1)).toBe('temporary:scoped-token:agent-a:request-1:Hello engineer');
328
    await app.close();
329
  });
330
331
  it('publishes only an owned agent public card', async () => {
332
    const published: any[] = [];
333
    const repository = {
334
      dashboard: async () => ({}) as any,
335
      getSettings: async () => ({}) as any,
336
      saveSettings: async () => ({}) as any,
337
      upsert: async () => undefined,
338
      list: async () => [
339
        {
340
          kind: 'agent_profile' as const,
341
          recordId: 'agent-one',
342
          payload: {
343
            agentId: 'agent-one',
344
            projectId: 'secret-project',
345
            name: 'Research agent',
346
            description: 'Finds primary sources',
347
            framework: 'Codex',
348
            agentVersion: '1.0.0',
349
            a2aEndpoint: 'https://agent.example/a2a',
350
            capabilities: ['research'],
351
            limitations: ['no purchases'],
352
            identityMode: 'oauth_installation' as const,
353
            status: 'active' as const,
354
            createdAt: '2026-01-01T00:00:00.000Z',
355
            updatedAt: '2026-01-01T00:00:00.000Z',
356
          },
357
        },
358
      ],
359
      publishAgent: async (_operatorId: string, card: any) => {
360
        published.push(card);
361
        return card;
362
      },
363
      unpublishAgent: async () => true,
364
      getPublishedAgent: async () => published[0],
365
      searchPublishedAgents: async () => published,
366
    };
367
    const app = buildApi(undefined, undefined, repository);
368
    await app.ready();
369
    const response = await app.inject({
370
      method: 'POST',
371
      url: '/v0.1/agents/agent-one/publication',
372
      headers: { 'x-openclasp-operator': 'owner-one' },
373
      payload: { published: true },
374
    });
375
    expect(response.statusCode).toBe(200);
376
    expect(published[0]).toMatchObject({
377
      agentId: 'agent-one',
378
      capabilities: ['research'],
379
      assurance: 'oauth_authenticated',
380
    });
381
    expect(published[0]).not.toHaveProperty('projectId');
382
    expect(published[0]).not.toHaveProperty('operatorId');
383
    const publicCard = await app.inject({
384
      method: 'GET',
385
      url: '/agents/agent-one/card.json',
386
    });
387
    expect(publicCard.statusCode).toBe(200);
388
    expect(publicCard.json()).not.toHaveProperty('projectId');
389
    const a2aCard = await app.inject({
390
      method: 'GET',
391
      url: '/agents/agent-one/a2a-agent-card.json',
392
    });
393
    expect(a2aCard.statusCode).toBe(200);
394
    expect(a2aCard.json().supportedInterfaces[0]).toMatchObject({
395
      url: 'https://agent.example/a2a',
396
      protocolVersion: '1.0',
397
    });
398
    const automation = await app.inject({
399
      method: 'PUT',
400
      url: '/v0.1/agents/agent-one/automation',
401
      headers: { 'x-openclasp-operator': 'owner-one' },
402
      payload: {
403
        a2aEndpoint: 'https://agent.example/a2a',
404
        autoPublish: true,
405
        autoAcceptPolicy: 'safe_matching',
406
        autoAcceptTaskCategories: ['research'],
407
      },
408
    });
409
    expect(automation.statusCode).toBe(200);
410
    expect(automation.json()).toMatchObject({
411
      autoPublish: true,
412
      autoAcceptPolicy: 'safe_matching',
413
    });
414
    await app.close();
415
  });
416
417
  it('accepts only structured live-session events at the reporting endpoint', async () => {
418
    const events: any[] = [];
419
    const repository = {
420
      dashboard: async () => ({}) as any,
421
      getSettings: async () => ({}) as any,
422
      saveSettings: async () => ({}) as any,
423
      upsert: async () => undefined,
424
      list: async () => [],
425
      publishAgent: async (_operatorId: string, card: any) => card,
426
      unpublishAgent: async () => true,
427
      getPublishedAgent: async () => undefined,
428
      searchPublishedAgents: async () => [],
429
      recordLiveSessionEvent: async (token: string, value: any) => {
430
        events.push({ token, value });
431
        return {
432
          recorded: true,
433
          deduplicated: false,
434
          eventId: value.eventId,
435
          attestation: {
436
            algorithm: 'Ed25519' as const,
437
            keyId: 'openclasp:test',
438
            value: 'signature',
439
            digest: 'digest',
440
          },
441
        };
442
      },
443
    };
444
    const app = buildApi(undefined, undefined, repository);
445
    await app.ready();
446
    const interactionId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
447
    expect(
448
      (await app.inject({ method: 'POST', url: `/sessions/${interactionId}/events` })).statusCode,
449
    ).toBe(401);
450
    const response = await app.inject({
451
      method: 'POST',
452
      url: `/sessions/${interactionId}/events`,
453
      headers: { authorization: 'Bearer live-session-token' },
454
      payload: {
455
        eventId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
456
        interactionId,
457
        agentId: 'agent-a',
458
        sequence: 1,
459
        type: 'message_sent',
460
        occurredAt: new Date().toISOString(),
461
        messageHash: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
462
        evidenceReferences: [],
463
        details: {},
464
      },
465
    });
466
    expect(response.statusCode).toBe(200);
467
    expect(events[0]).toMatchObject({
468
      token: 'live-session-token',
469
      value: { interactionId, agentId: 'agent-a', type: 'message_sent' },
470
    });
471
    const rawMessageAttempt = await app.inject({
472
      method: 'POST',
473
      url: `/sessions/${interactionId}/events`,
474
      headers: { authorization: 'Bearer live-session-token' },
475
      payload: {
476
        eventId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
477
        interactionId,
478
        agentId: 'agent-a',
479
        sequence: 2,
480
        type: 'message_sent',
481
        occurredAt: new Date().toISOString(),
482
        details: { message: 'raw conversation text' },
483
      },
484
    });
485
    expect(rawMessageAttempt.statusCode).toBe(400);
486
    expect(events).toHaveLength(1);
487
    await app.close();
488
  });
489
});