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

Viewing files at commit 5239911501a5Commit detailsView latest

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