@haiai/haiai 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +127 -0
  2. package/bin/haiai.cjs +70 -0
  3. package/dist/cjs/a2a.js +352 -0
  4. package/dist/cjs/a2a.js.map +1 -0
  5. package/dist/cjs/agent.js +236 -0
  6. package/dist/cjs/agent.js.map +1 -0
  7. package/dist/cjs/client.js +2168 -0
  8. package/dist/cjs/client.js.map +1 -0
  9. package/dist/cjs/config.js +176 -0
  10. package/dist/cjs/config.js.map +1 -0
  11. package/dist/cjs/errors.js +102 -0
  12. package/dist/cjs/errors.js.map +1 -0
  13. package/dist/cjs/hash.js +52 -0
  14. package/dist/cjs/hash.js.map +1 -0
  15. package/dist/cjs/index.js +84 -0
  16. package/dist/cjs/index.js.map +1 -0
  17. package/dist/cjs/integrations.js +193 -0
  18. package/dist/cjs/integrations.js.map +1 -0
  19. package/dist/cjs/jacs.js +66 -0
  20. package/dist/cjs/jacs.js.map +1 -0
  21. package/dist/cjs/mime.js +100 -0
  22. package/dist/cjs/mime.js.map +1 -0
  23. package/dist/cjs/package.json +3 -0
  24. package/dist/cjs/signing.js +190 -0
  25. package/dist/cjs/signing.js.map +1 -0
  26. package/dist/cjs/sse.js +76 -0
  27. package/dist/cjs/sse.js.map +1 -0
  28. package/dist/cjs/types.js +6 -0
  29. package/dist/cjs/types.js.map +1 -0
  30. package/dist/cjs/verify.js +76 -0
  31. package/dist/cjs/verify.js.map +1 -0
  32. package/dist/cjs/ws.js +206 -0
  33. package/dist/cjs/ws.js.map +1 -0
  34. package/dist/esm/a2a.js +305 -0
  35. package/dist/esm/a2a.js.map +1 -0
  36. package/dist/esm/agent.js +231 -0
  37. package/dist/esm/agent.js.map +1 -0
  38. package/dist/esm/client.js +2131 -0
  39. package/dist/esm/client.js.map +1 -0
  40. package/dist/esm/config.js +171 -0
  41. package/dist/esm/config.js.map +1 -0
  42. package/dist/esm/errors.js +88 -0
  43. package/dist/esm/errors.js.map +1 -0
  44. package/dist/esm/hash.js +49 -0
  45. package/dist/esm/hash.js.map +1 -0
  46. package/dist/esm/index.js +27 -0
  47. package/dist/esm/index.js.map +1 -0
  48. package/dist/esm/integrations.js +147 -0
  49. package/dist/esm/integrations.js.map +1 -0
  50. package/dist/esm/jacs.js +61 -0
  51. package/dist/esm/jacs.js.map +1 -0
  52. package/dist/esm/mime.js +97 -0
  53. package/dist/esm/mime.js.map +1 -0
  54. package/dist/esm/signing.js +183 -0
  55. package/dist/esm/signing.js.map +1 -0
  56. package/dist/esm/sse.js +73 -0
  57. package/dist/esm/sse.js.map +1 -0
  58. package/dist/esm/types.js +5 -0
  59. package/dist/esm/types.js.map +1 -0
  60. package/dist/esm/verify.js +72 -0
  61. package/dist/esm/verify.js.map +1 -0
  62. package/dist/esm/ws.js +168 -0
  63. package/dist/esm/ws.js.map +1 -0
  64. package/dist/types/a2a.d.ts +52 -0
  65. package/dist/types/a2a.d.ts.map +1 -0
  66. package/dist/types/agent.d.ts +202 -0
  67. package/dist/types/agent.d.ts.map +1 -0
  68. package/dist/types/client.d.ts +486 -0
  69. package/dist/types/client.d.ts.map +1 -0
  70. package/dist/types/config.d.ts +31 -0
  71. package/dist/types/config.d.ts.map +1 -0
  72. package/dist/types/errors.d.ts +50 -0
  73. package/dist/types/errors.d.ts.map +1 -0
  74. package/dist/types/hash.d.ts +32 -0
  75. package/dist/types/hash.d.ts.map +1 -0
  76. package/dist/types/index.d.ts +22 -0
  77. package/dist/types/index.d.ts.map +1 -0
  78. package/dist/types/integrations.d.ts +25 -0
  79. package/dist/types/integrations.d.ts.map +1 -0
  80. package/dist/types/jacs.d.ts +26 -0
  81. package/dist/types/jacs.d.ts.map +1 -0
  82. package/dist/types/mime.d.ts +39 -0
  83. package/dist/types/mime.d.ts.map +1 -0
  84. package/dist/types/signing.d.ts +58 -0
  85. package/dist/types/signing.d.ts.map +1 -0
  86. package/dist/types/sse.d.ts +8 -0
  87. package/dist/types/sse.d.ts.map +1 -0
  88. package/dist/types/types.d.ts +652 -0
  89. package/dist/types/types.d.ts.map +1 -0
  90. package/dist/types/verify.d.ts +20 -0
  91. package/dist/types/verify.d.ts.map +1 -0
  92. package/dist/types/ws.d.ts +30 -0
  93. package/dist/types/ws.d.ts.map +1 -0
  94. package/examples/a2a_quickstart.ts +138 -0
  95. package/examples/hai_quickstart.ts +111 -0
  96. package/examples/mcp_quickstart.ts +53 -0
  97. package/npm/@haiai/cli-darwin-arm64/package.json +16 -0
  98. package/npm/@haiai/cli-darwin-x64/package.json +16 -0
  99. package/npm/@haiai/cli-linux-arm64/package.json +16 -0
  100. package/npm/@haiai/cli-linux-x64/package.json +16 -0
  101. package/npm/@haiai/cli-win32-x64/package.json +16 -0
  102. package/package.json +68 -0
  103. package/scripts/build-platform-packages.js +132 -0
  104. package/scripts/smoke-package.cjs +114 -0
  105. package/scripts/write-cjs-package.cjs +9 -0
  106. package/src/a2a.ts +463 -0
  107. package/src/agent.ts +302 -0
  108. package/src/client.ts +2504 -0
  109. package/src/config.ts +204 -0
  110. package/src/errors.ts +99 -0
  111. package/src/hash.ts +66 -0
  112. package/src/index.ts +163 -0
  113. package/src/integrations.ts +210 -0
  114. package/src/jacs.ts +86 -0
  115. package/src/mime.ts +131 -0
  116. package/src/signing.ts +233 -0
  117. package/src/sse.ts +86 -0
  118. package/src/types.ts +773 -0
  119. package/src/verify.ts +89 -0
  120. package/src/ws.ts +198 -0
  121. package/tests/_debug_jacs.cjs +29 -0
  122. package/tests/a2a-contract.test.ts +271 -0
  123. package/tests/a2a-fixtures.test.ts +73 -0
  124. package/tests/a2a.test.ts +379 -0
  125. package/tests/binary.test.ts +90 -0
  126. package/tests/client-api-methods.test.ts +176 -0
  127. package/tests/client-path-escaping.test.ts +80 -0
  128. package/tests/client-register.test.ts +61 -0
  129. package/tests/config.test.ts +281 -0
  130. package/tests/contract.test.ts +360 -0
  131. package/tests/cross-lang-contract.test.ts +67 -0
  132. package/tests/email-conformance.test.ts +289 -0
  133. package/tests/email-integration.test.ts +217 -0
  134. package/tests/email.test.ts +767 -0
  135. package/tests/errors.test.ts +167 -0
  136. package/tests/init-contract.test.ts +129 -0
  137. package/tests/integrations.test.ts +132 -0
  138. package/tests/jacs-passthrough.test.ts +125 -0
  139. package/tests/key-cache.test.ts +201 -0
  140. package/tests/key-integration.test.ts +119 -0
  141. package/tests/key-lookups.test.ts +187 -0
  142. package/tests/key-rotation.test.ts +362 -0
  143. package/tests/mime.test.ts +127 -0
  144. package/tests/security.test.ts +109 -0
  145. package/tests/setup.ts +60 -0
  146. package/tests/signing.test.ts +142 -0
  147. package/tests/sse.test.ts +125 -0
  148. package/tests/types.test.ts +294 -0
  149. package/tests/verify-link.test.ts +81 -0
  150. package/tests/ws.test.ts +213 -0
  151. package/tsconfig.cjs.json +11 -0
  152. package/tsconfig.json +22 -0
  153. package/vitest.config.ts +11 -0
@@ -0,0 +1,362 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { HaiClient } from '../src/client.js';
3
+ import { generateTestKeypair as generateKeypair } from './setup.js';
4
+ import * as fs from 'node:fs/promises';
5
+ import * as path from 'node:path';
6
+ import * as os from 'node:os';
7
+
8
+ /** Create a test client with a generated keypair and a temporary key directory. */
9
+ async function setupTestAgent(tmpDir: string) {
10
+ const keypair = generateKeypair();
11
+ const keyDir = path.join(tmpDir, 'keys');
12
+ await fs.mkdir(keyDir, { recursive: true });
13
+
14
+ // Write key files
15
+ const privPath = path.join(keyDir, 'agent_private_key.pem');
16
+ const pubPath = path.join(keyDir, 'agent_public_key.pem');
17
+ await fs.writeFile(privPath, keypair.privateKeyPem, { mode: 0o600 });
18
+ await fs.writeFile(pubPath, keypair.publicKeyPem, { mode: 0o644 });
19
+
20
+ // Write config file
21
+ const config = {
22
+ jacsAgentName: 'test-rotation-agent',
23
+ jacsAgentVersion: 'v1-original',
24
+ jacsKeyDir: keyDir,
25
+ jacsId: 'test-jacs-id-12345',
26
+ };
27
+ const configPath = path.join(tmpDir, 'jacs.config.json');
28
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2));
29
+
30
+ // Build client via fromCredentials (now async)
31
+ const client = await HaiClient.fromCredentials(
32
+ config.jacsId,
33
+ keypair.privateKeyPem,
34
+ { url: 'https://hai.example', privateKeyPassphrase: 'keygen-password' },
35
+ );
36
+ // Patch the config to have the real keyDir and version
37
+ (client as any).config = { ...config };
38
+
39
+ return { client, keypair, keyDir, privPath, pubPath, configPath, config };
40
+ }
41
+
42
+ describe('rotateKeys', () => {
43
+ let tmpDir: string;
44
+
45
+ beforeEach(async () => {
46
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'hai-rotation-test-'));
47
+ });
48
+
49
+ afterEach(async () => {
50
+ vi.unstubAllGlobals();
51
+ vi.restoreAllMocks();
52
+ // Clean up temp directory
53
+ await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
54
+ });
55
+
56
+ it('generates new key files and archives old ones', async () => {
57
+ const { client, privPath, pubPath, keyDir, configPath } = await setupTestAgent(tmpDir);
58
+
59
+ // Stub fetch so register() doesn't make real requests
60
+ vi.stubGlobal('fetch', vi.fn());
61
+
62
+ // Set JACS_CONFIG_PATH to point to our tmp config
63
+ process.env.JACS_CONFIG_PATH = configPath;
64
+
65
+ const result = await client.rotateKeys({ registerWithHai: false });
66
+
67
+ // New key files should exist at standard paths
68
+ const newPrivExists = await fs.stat(privPath).then(() => true).catch(() => false);
69
+ const newPubExists = await fs.stat(pubPath).then(() => true).catch(() => false);
70
+ expect(newPrivExists).toBe(true);
71
+ expect(newPubExists).toBe(true);
72
+
73
+ // Old keys should be archived with version suffix
74
+ const archivePriv = path.join(keyDir, 'agent_private_key.v1-original.pem');
75
+ const archiveExists = await fs.stat(archivePriv).then(() => true).catch(() => false);
76
+ expect(archiveExists).toBe(true);
77
+
78
+ delete process.env.JACS_CONFIG_PATH;
79
+ });
80
+
81
+ it('returns a valid RotationResult with correct fields', async () => {
82
+ const { client, configPath } = await setupTestAgent(tmpDir);
83
+ vi.stubGlobal('fetch', vi.fn());
84
+ process.env.JACS_CONFIG_PATH = configPath;
85
+
86
+ const result = await client.rotateKeys({ registerWithHai: false });
87
+
88
+ expect(result.jacsId).toBe('test-jacs-id-12345');
89
+ expect(result.oldVersion).toBe('v1-original');
90
+ expect(result.newVersion).not.toBe('v1-original');
91
+ expect(result.newVersion.length).toBeGreaterThan(0);
92
+ // SHA-256 hex is 64 chars
93
+ expect(result.newPublicKeyHash).toHaveLength(64);
94
+ expect(result.registeredWithHai).toBe(false);
95
+ expect(result.signedAgentJson.length).toBeGreaterThan(0);
96
+
97
+ // Signed agent JSON should be valid JSON with expected fields
98
+ const doc = JSON.parse(result.signedAgentJson);
99
+ expect(doc.jacsId).toBe('test-jacs-id-12345');
100
+ expect(doc.jacsVersion).toBe(result.newVersion);
101
+ expect(doc.jacsPreviousVersion).toBe('v1-original');
102
+ expect(doc.jacsSignature).toBeDefined();
103
+
104
+ delete process.env.JACS_CONFIG_PATH;
105
+ });
106
+
107
+ it('updates config file with new version', async () => {
108
+ const { client, configPath } = await setupTestAgent(tmpDir);
109
+ vi.stubGlobal('fetch', vi.fn());
110
+ process.env.JACS_CONFIG_PATH = configPath;
111
+
112
+ const result = await client.rotateKeys({ registerWithHai: false });
113
+
114
+ // Read config file and verify version was updated
115
+ const updatedConfig = JSON.parse(await fs.readFile(configPath, 'utf-8'));
116
+ expect(updatedConfig.jacsAgentVersion).toBe(result.newVersion);
117
+ // jacsId should be unchanged
118
+ expect(updatedConfig.jacsId).toBe('test-jacs-id-12345');
119
+
120
+ delete process.env.JACS_CONFIG_PATH;
121
+ });
122
+
123
+ it('updates in-memory config version', async () => {
124
+ const { client, configPath } = await setupTestAgent(tmpDir);
125
+ vi.stubGlobal('fetch', vi.fn());
126
+ process.env.JACS_CONFIG_PATH = configPath;
127
+
128
+ const result = await client.rotateKeys({ registerWithHai: false });
129
+
130
+ // In-memory config should reflect the new version
131
+ expect((client as any).config.jacsAgentVersion).toBe(result.newVersion);
132
+ // The private key PEM should have changed
133
+ expect((client as any).privateKeyPem).not.toBe('');
134
+
135
+ delete process.env.JACS_CONFIG_PATH;
136
+ });
137
+
138
+ it('calls register when registerWithHai is true', async () => {
139
+ const { client, configPath } = await setupTestAgent(tmpDir);
140
+ process.env.JACS_CONFIG_PATH = configPath;
141
+
142
+ const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => {
143
+ return new Response(
144
+ JSON.stringify({
145
+ agent_id: 'hai-agent-uuid',
146
+ jacs_id: 'test-jacs-id-12345',
147
+ hai_signature: 'sig-abc',
148
+ registration_id: 'reg-123',
149
+ registered_at: '2026-03-02T00:00:00Z',
150
+ }),
151
+ { status: 201, headers: { 'Content-Type': 'application/json' } },
152
+ );
153
+ });
154
+ vi.stubGlobal('fetch', fetchMock);
155
+
156
+ const result = await client.rotateKeys({
157
+ registerWithHai: true,
158
+ haiUrl: 'https://hai.example',
159
+ });
160
+
161
+ expect(result.registeredWithHai).toBe(true);
162
+ // Verify register was called (fetch was invoked)
163
+ expect(fetchMock).toHaveBeenCalled();
164
+ const callUrl = String(fetchMock.mock.calls[0][0]);
165
+ expect(callUrl).toContain('/api/v1/agents/register');
166
+
167
+ delete process.env.JACS_CONFIG_PATH;
168
+ });
169
+
170
+ it('preserves local rotation when HAI registration fails', async () => {
171
+ const { client, configPath } = await setupTestAgent(tmpDir);
172
+ process.env.JACS_CONFIG_PATH = configPath;
173
+
174
+ const fetchMock = vi.fn(async () => {
175
+ return new Response('Internal Server Error', {
176
+ status: 500,
177
+ headers: { 'Content-Type': 'text/plain' },
178
+ });
179
+ });
180
+ vi.stubGlobal('fetch', fetchMock);
181
+
182
+ const result = await client.rotateKeys({
183
+ registerWithHai: true,
184
+ haiUrl: 'https://hai.example',
185
+ });
186
+
187
+ // Local rotation should succeed
188
+ expect(result.jacsId).toBe('test-jacs-id-12345');
189
+ expect(result.newVersion).not.toBe('v1-original');
190
+ // But HAI registration should have failed
191
+ expect(result.registeredWithHai).toBe(false);
192
+
193
+ delete process.env.JACS_CONFIG_PATH;
194
+ });
195
+
196
+ it('throws when no jacsId is set', async () => {
197
+ const keypair = generateKeypair();
198
+ const client = await HaiClient.fromCredentials(
199
+ 'no-id-agent',
200
+ keypair.privateKeyPem,
201
+ { url: 'https://hai.example', privateKeyPassphrase: 'keygen-password' },
202
+ );
203
+ // Remove jacsId from config
204
+ (client as any).config = {
205
+ jacsAgentName: 'no-id-agent',
206
+ jacsAgentVersion: 'v1',
207
+ jacsKeyDir: '/nonexistent',
208
+ };
209
+
210
+ await expect(client.rotateKeys({ registerWithHai: false }))
211
+ .rejects.toThrow(/no jacsId/i);
212
+ });
213
+
214
+ it('throws when private key file not found', async () => {
215
+ const keypair = generateKeypair();
216
+ const emptyKeyDir = path.join(tmpDir, 'empty-keys');
217
+ await fs.mkdir(emptyKeyDir, { recursive: true });
218
+
219
+ const client = await HaiClient.fromCredentials(
220
+ 'test-id',
221
+ keypair.privateKeyPem,
222
+ { url: 'https://hai.example', privateKeyPassphrase: 'keygen-password' },
223
+ );
224
+ (client as any).config = {
225
+ jacsAgentName: 'test-agent',
226
+ jacsAgentVersion: 'v1',
227
+ jacsKeyDir: emptyKeyDir,
228
+ jacsId: 'test-id',
229
+ };
230
+
231
+ await expect(client.rotateKeys({ registerWithHai: false }))
232
+ .rejects.toThrow(/private key not found/i);
233
+ });
234
+
235
+ it('new key produces valid signature on agent document', async () => {
236
+ const { client, configPath, pubPath } = await setupTestAgent(tmpDir);
237
+ vi.stubGlobal('fetch', vi.fn());
238
+ process.env.JACS_CONFIG_PATH = configPath;
239
+
240
+ const result = await client.rotateKeys({ registerWithHai: false });
241
+
242
+ // Read the new public key from disk
243
+ const newPubPem = await fs.readFile(pubPath, 'utf-8');
244
+ expect(newPubPem).toContain('-----BEGIN PUBLIC KEY-----');
245
+
246
+ // Parse signed agent JSON and ensure the signed payload references the
247
+ // rotated key/version material. The native binding currently cannot
248
+ // reload freshly generated temp agents reliably in-process, so this test
249
+ // stays at the contract/document level rather than duplicating JACS
250
+ // verification behavior.
251
+ const doc = JSON.parse(result.signedAgentJson);
252
+ expect(doc.jacsSignature?.signature).toBeDefined();
253
+ expect(doc.jacsPublicKey).toContain('BEGIN PUBLIC KEY');
254
+ expect(doc.jacsVersion).toBe(result.newVersion);
255
+ expect(doc.jacsPreviousVersion).toBe('v1-original');
256
+
257
+ delete process.env.JACS_CONFIG_PATH;
258
+ });
259
+
260
+ it('new version is a valid UUID v4', async () => {
261
+ const { client, configPath } = await setupTestAgent(tmpDir);
262
+ vi.stubGlobal('fetch', vi.fn());
263
+ process.env.JACS_CONFIG_PATH = configPath;
264
+
265
+ const result = await client.rotateKeys({ registerWithHai: false });
266
+
267
+ // UUID v4 pattern: 8-4-4-4-12 hex chars
268
+ const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
269
+ expect(result.newVersion).toMatch(uuidRegex);
270
+
271
+ delete process.env.JACS_CONFIG_PATH;
272
+ });
273
+
274
+ it('double rotation archives both versions', async () => {
275
+ const { client, configPath, keyDir, privPath, pubPath } = await setupTestAgent(tmpDir);
276
+ vi.stubGlobal('fetch', vi.fn());
277
+ process.env.JACS_CONFIG_PATH = configPath;
278
+
279
+ // First rotation: V1 -> V2
280
+ const result1 = await client.rotateKeys({ registerWithHai: false });
281
+ const v2 = result1.newVersion;
282
+
283
+ // Second rotation: V2 -> V3
284
+ const result2 = await client.rotateKeys({ registerWithHai: false });
285
+
286
+ // Current key files should exist
287
+ const newPrivExists = await fs.stat(privPath).then(() => true).catch(() => false);
288
+ const newPubExists = await fs.stat(pubPath).then(() => true).catch(() => false);
289
+ expect(newPrivExists).toBe(true);
290
+ expect(newPubExists).toBe(true);
291
+
292
+ // V1 archive should exist
293
+ const archiveV1 = path.join(keyDir, 'agent_private_key.v1-original.pem');
294
+ const v1Exists = await fs.stat(archiveV1).then(() => true).catch(() => false);
295
+ expect(v1Exists).toBe(true);
296
+
297
+ // V2 archive should exist
298
+ const archiveV2 = path.join(keyDir, `agent_private_key.${v2}.pem`);
299
+ const v2Exists = await fs.stat(archiveV2).then(() => true).catch(() => false);
300
+ expect(v2Exists).toBe(true);
301
+
302
+ // Version chain should be consistent
303
+ expect(result1.oldVersion).toBe('v1-original');
304
+ expect(result1.newVersion).toBe(result2.oldVersion);
305
+ expect(result2.newVersion).not.toBe(result2.oldVersion);
306
+
307
+ delete process.env.JACS_CONFIG_PATH;
308
+ });
309
+
310
+ it('rotation result fields match shared fixture contract', async () => {
311
+ const fixturePath = path.join(__dirname, '..', '..', 'fixtures', 'rotation_result.json');
312
+ let fixture: Record<string, unknown>;
313
+ try {
314
+ fixture = JSON.parse(await fs.readFile(fixturePath, 'utf-8'));
315
+ } catch {
316
+ // Skip if fixture doesn't exist
317
+ return;
318
+ }
319
+
320
+ // RotationResult interface fields (snake_case in fixture -> camelCase in TS)
321
+ const fixtureKeys = new Set(Object.keys(fixture));
322
+ const expectedKeys = new Set([
323
+ 'jacs_id', 'old_version', 'new_version',
324
+ 'new_public_key_hash', 'registered_with_hai', 'signed_agent_json',
325
+ ]);
326
+ expect(fixtureKeys).toEqual(expectedKeys);
327
+ });
328
+
329
+ it('register payload contains agent_json with new version', async () => {
330
+ const { client, configPath } = await setupTestAgent(tmpDir);
331
+ process.env.JACS_CONFIG_PATH = configPath;
332
+
333
+ let capturedBody: Record<string, unknown> | null = null;
334
+ const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
335
+ capturedBody = JSON.parse(String(init?.body ?? '{}'));
336
+ return new Response(
337
+ JSON.stringify({
338
+ agent_id: 'hai-uuid',
339
+ jacs_id: 'test-jacs-id-12345',
340
+ hai_signature: 'sig',
341
+ registration_id: 'reg-1',
342
+ registered_at: '2026-03-02T00:00:00Z',
343
+ }),
344
+ { status: 201, headers: { 'Content-Type': 'application/json' } },
345
+ );
346
+ });
347
+ vi.stubGlobal('fetch', fetchMock);
348
+
349
+ const result = await client.rotateKeys({
350
+ registerWithHai: true,
351
+ haiUrl: 'https://hai.example',
352
+ });
353
+
354
+ expect(capturedBody).not.toBeNull();
355
+ expect(capturedBody!.agent_json).toBeDefined();
356
+ const agentDoc = JSON.parse(capturedBody!.agent_json as string);
357
+ expect(agentDoc.jacsVersion).toBe(result.newVersion);
358
+ expect(agentDoc.jacsId).toBe('test-jacs-id-12345');
359
+
360
+ delete process.env.JACS_CONFIG_PATH;
361
+ });
362
+ });
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildRfc5322Email } from '../src/mime.js';
3
+ import type { MimeSendEmailOptions } from '../src/mime.js';
4
+
5
+ describe('buildRfc5322Email', () => {
6
+ it('produces valid RFC 5322 for simple text email', () => {
7
+ const opts: MimeSendEmailOptions = {
8
+ to: 'recipient@hai.ai',
9
+ subject: 'Test Subject',
10
+ body: 'Hello, world!',
11
+ };
12
+ const raw = buildRfc5322Email(opts, 'sender@hai.ai');
13
+ const text = raw.toString('utf-8');
14
+
15
+ expect(text).toContain('From: <sender@hai.ai>\r\n');
16
+ expect(text).toContain('To: recipient@hai.ai\r\n');
17
+ expect(text).toContain('Subject: Test Subject\r\n');
18
+ expect(text).toContain('Date: ');
19
+ expect(text).toContain('Message-ID: <');
20
+ expect(text).toContain('MIME-Version: 1.0\r\n');
21
+ expect(text).toContain('Content-Type: text/plain; charset=utf-8\r\n');
22
+ expect(text).toContain('Hello, world!');
23
+ });
24
+
25
+ it('handles attachments', () => {
26
+ const opts: MimeSendEmailOptions = {
27
+ to: 'recipient@hai.ai',
28
+ subject: 'With Attachments',
29
+ body: 'See attached.',
30
+ attachments: [
31
+ {
32
+ filename: 'file1.txt',
33
+ contentType: 'text/plain',
34
+ data: Buffer.from('content of file 1'),
35
+ },
36
+ {
37
+ filename: 'file2.pdf',
38
+ contentType: 'application/pdf',
39
+ data: Buffer.from('fake pdf content'),
40
+ },
41
+ ],
42
+ };
43
+
44
+ const raw = buildRfc5322Email(opts, 'sender@hai.ai');
45
+ const text = raw.toString('utf-8');
46
+
47
+ expect(text).toContain('Content-Type: multipart/mixed; boundary=');
48
+ expect(text).toContain('Content-Disposition: attachment; filename="file1.txt"');
49
+ expect(text).toContain('Content-Disposition: attachment; filename="file2.pdf"');
50
+ expect(text).toContain('Content-Transfer-Encoding: base64');
51
+ expect(text).toContain('See attached.');
52
+ });
53
+
54
+ it('handles reply threading', () => {
55
+ const opts: MimeSendEmailOptions = {
56
+ to: 'recipient@hai.ai',
57
+ subject: 'Re: Original',
58
+ body: 'Reply body',
59
+ inReplyTo: '<original-id@hai.ai>',
60
+ };
61
+
62
+ const raw = buildRfc5322Email(opts, 'sender@hai.ai');
63
+ const text = raw.toString('utf-8');
64
+
65
+ expect(text).toContain('In-Reply-To: <original-id@hai.ai>\r\n');
66
+ expect(text).toContain('References: <original-id@hai.ai>\r\n');
67
+ });
68
+
69
+ it('sanitizes CRLF injection', () => {
70
+ const opts: MimeSendEmailOptions = {
71
+ to: 'recipient@hai.ai',
72
+ subject: 'Bad\r\nBcc: attacker@evil.com',
73
+ body: 'Body',
74
+ };
75
+
76
+ const raw = buildRfc5322Email(opts, 'sender@hai.ai');
77
+ const text = raw.toString('utf-8');
78
+
79
+ // No line should start with "Bcc:" (CRLF injection prevented)
80
+ const lines = text.split('\r\n');
81
+ for (const line of lines) {
82
+ expect(line.startsWith('Bcc:')).toBe(false);
83
+ }
84
+ // Subject should be sanitized
85
+ expect(text).toContain('Subject: BadBcc: attacker@evil.com\r\n');
86
+ });
87
+
88
+ it('sanitizes double quotes in filenames to prevent parameter injection', () => {
89
+ const opts: MimeSendEmailOptions = {
90
+ to: 'recipient@hai.ai',
91
+ subject: 'Test',
92
+ body: 'Body',
93
+ attachments: [
94
+ {
95
+ filename: 'file"; name="evil',
96
+ contentType: 'text/plain',
97
+ data: Buffer.from('content'),
98
+ },
99
+ ],
100
+ };
101
+
102
+ const raw = buildRfc5322Email(opts, 'sender@hai.ai');
103
+ const text = raw.toString('utf-8');
104
+
105
+ expect(text).not.toContain('filename="file"');
106
+ expect(text).not.toContain('name="evil"');
107
+ });
108
+
109
+ it('uses CRLF line endings', () => {
110
+ const opts: MimeSendEmailOptions = {
111
+ to: 'recipient@hai.ai',
112
+ subject: 'Test',
113
+ body: 'Body',
114
+ };
115
+
116
+ const raw = buildRfc5322Email(opts, 'sender@hai.ai');
117
+ const text = raw.toString('utf-8');
118
+
119
+ expect(text).toContain('\r\n');
120
+ // Split by CRLF, no remaining bare \n in any part (except within body text)
121
+ const headerSection = text.split('\r\n\r\n')[0];
122
+ const headerParts = headerSection.split('\r\n');
123
+ for (const part of headerParts) {
124
+ expect(part).not.toContain('\n');
125
+ }
126
+ });
127
+ });
@@ -0,0 +1,109 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { HaiClient } from '../src/client.js';
3
+ import { generateTestKeypair as generateKeypair } from './setup.js';
4
+
5
+ async function makeClient(): Promise<HaiClient> {
6
+ const kp = generateKeypair();
7
+ const client = await HaiClient.fromCredentials('security-agent', kp.privateKeyPem, {
8
+ url: 'https://hai.example',
9
+ privateKeyPassphrase: 'keygen-password',
10
+ });
11
+ (client as any)._publicKeyPem = kp.publicKeyPem;
12
+ return client;
13
+ }
14
+
15
+ describe('security behaviors (node)', () => {
16
+ afterEach(() => {
17
+ vi.unstubAllGlobals();
18
+ vi.restoreAllMocks();
19
+ });
20
+
21
+ it('register does not send private key material and keeps bootstrap request unauthenticated', async () => {
22
+ const client = await makeClient();
23
+
24
+ const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
25
+ const headers = new Headers(init?.headers);
26
+ expect(headers.get('Authorization')).toBeNull();
27
+ expect(headers.get('Content-Type')).toBe('application/json');
28
+
29
+ const rawBody = String(init?.body ?? '');
30
+ expect(rawBody).not.toContain('BEGIN PRIVATE KEY');
31
+
32
+ const payload = JSON.parse(rawBody) as Record<string, string>;
33
+ expect(typeof payload.agent_json).toBe('string');
34
+ expect(payload.public_key).toBeTypeOf('string');
35
+
36
+ return new Response(JSON.stringify({
37
+ agent_id: 'agent-123',
38
+ jacs_id: 'security-agent',
39
+ registered_at: '2026-01-01T00:00:00Z',
40
+ }), {
41
+ status: 201,
42
+ headers: { 'Content-Type': 'application/json' },
43
+ });
44
+ });
45
+ vi.stubGlobal('fetch', fetchMock);
46
+
47
+ await client.register({
48
+ ownerEmail: 'owner@hai.ai',
49
+ domain: 'agent.example',
50
+ description: 'Security test agent',
51
+ });
52
+ });
53
+
54
+ it('checkUsername remains unauthenticated public endpoint', async () => {
55
+ const client = await makeClient();
56
+
57
+ const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
58
+ const headers = new Headers(init?.headers);
59
+ expect(headers.get('Authorization')).toBeNull();
60
+
61
+ return new Response(JSON.stringify({
62
+ available: true,
63
+ username: 'agent',
64
+ }), {
65
+ status: 200,
66
+ headers: { 'Content-Type': 'application/json' },
67
+ });
68
+ });
69
+ vi.stubGlobal('fetch', fetchMock);
70
+
71
+ const result = await client.checkUsername('agent');
72
+ expect(result.available).toBe(true);
73
+ });
74
+
75
+ it('registerNewAgent omits Authorization and sends base64 public key only', async () => {
76
+ const client = await makeClient();
77
+
78
+ const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => {
79
+ const headers = new Headers(init?.headers);
80
+ expect(headers.get('Authorization')).toBeNull();
81
+ expect(headers.get('Content-Type')).toBe('application/json');
82
+
83
+ const payload = JSON.parse(String(init?.body ?? '{}')) as Record<string, string>;
84
+ const decodedPublicKey = Buffer.from(payload.public_key, 'base64').toString('utf-8');
85
+ expect(decodedPublicKey).toContain('BEGIN PUBLIC KEY');
86
+ expect(decodedPublicKey).not.toContain('BEGIN PRIVATE KEY');
87
+
88
+ return new Response(JSON.stringify({
89
+ agent_id: 'agent-999',
90
+ jacs_id: 'security-agent',
91
+ registration_id: 'reg-999',
92
+ registered_at: '2026-01-01T00:00:00Z',
93
+ }), {
94
+ status: 201,
95
+ headers: { 'Content-Type': 'application/json' },
96
+ });
97
+ });
98
+ vi.stubGlobal('fetch', fetchMock);
99
+
100
+ const result = await client.registerNewAgent('security-agent', {
101
+ ownerEmail: 'owner@hai.ai',
102
+ domain: 'agent.example',
103
+ description: 'Security bootstrap',
104
+ quiet: true,
105
+ });
106
+
107
+ expect(result.agentId).toBe('agent-999');
108
+ });
109
+ });
package/tests/setup.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { generateKeyPairSync } from 'node:crypto';
2
+ import { JacsAgent } from '@hai.ai/jacs';
3
+
4
+ /** Strong test password that meets JACS security requirements. */
5
+ export const TEST_PASSWORD = 'Xk9#mP2vL7qR4!nB8wZ';
6
+
7
+ /** A test JACS ID. */
8
+ export const TEST_JACS_ID = 'test-agent-001';
9
+
10
+ /** The test JACS agent (ephemeral, in-memory). Can sign and verify. */
11
+ export const TEST_AGENT = new JacsAgent();
12
+ const _ephResult = JSON.parse(TEST_AGENT.ephemeralSync('ring-Ed25519'));
13
+
14
+ /**
15
+ * A PEM-encoded public key from a generated Ed25519 keypair.
16
+ * Used for tests that need deterministic PEM-shaped key material without
17
+ * depending on JACS on-disk agent layout.
18
+ */
19
+ export const TEST_PUBLIC_KEY_PEM = (() => {
20
+ const { publicKey } = generateKeyPairSync('ed25519');
21
+ return publicKey.export({ format: 'pem', type: 'spki' }).toString().trim();
22
+ })();
23
+
24
+ /**
25
+ * Generate an Ed25519 keypair as plaintext PEM for tests that only need
26
+ * stable key fixtures or a public/private pair written to disk.
27
+ */
28
+ export function generateTestKeypair(): { publicKeyPem: string; privateKeyPem: string } {
29
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
30
+ return {
31
+ publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString().trim(),
32
+ privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString().trim(),
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Create a mock SSE response body from a list of events.
38
+ */
39
+ export function createSseBody(events: Array<{ event?: string; data: string; id?: string }>): ReadableStream<Uint8Array> {
40
+ const encoder = new TextEncoder();
41
+ let sent = false;
42
+
43
+ return new ReadableStream({
44
+ pull(controller) {
45
+ if (sent) {
46
+ controller.close();
47
+ return;
48
+ }
49
+ sent = true;
50
+
51
+ let text = '';
52
+ for (const evt of events) {
53
+ if (evt.event) text += `event: ${evt.event}\n`;
54
+ if (evt.id) text += `id: ${evt.id}\n`;
55
+ text += `data: ${evt.data}\n\n`;
56
+ }
57
+ controller.enqueue(encoder.encode(text));
58
+ },
59
+ });
60
+ }