@vellumai/assistant 0.5.8 → 0.5.10

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 (35) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +8 -8
  3. package/README.md +1 -1
  4. package/docs/architecture/integrations.md +4 -4
  5. package/docs/architecture/keychain-broker.md +17 -18
  6. package/docs/architecture/security.md +5 -5
  7. package/eslint.config.mjs +0 -31
  8. package/package.json +1 -1
  9. package/src/__tests__/conversation-runtime-assembly.test.ts +227 -0
  10. package/src/__tests__/credentials-cli.test.ts +3 -3
  11. package/src/__tests__/stt-hints.test.ts +22 -22
  12. package/src/__tests__/voice-quality.test.ts +2 -2
  13. package/src/__tests__/workspace-migration-015-migrate-credentials-to-keychain.test.ts +5 -238
  14. package/src/__tests__/workspace-migration-016-migrate-credentials-from-keychain.test.ts +5 -206
  15. package/src/__tests__/workspace-migrations-runner.test.ts +15 -7
  16. package/src/cli/commands/credentials.ts +4 -4
  17. package/src/cli/commands/oauth/apps.ts +3 -3
  18. package/src/daemon/conversation-agent-loop.ts +6 -0
  19. package/src/daemon/conversation-runtime-assembly.ts +61 -1
  20. package/src/daemon/lifecycle.ts +2 -3
  21. package/src/memory/migrations/validate-migration-state.ts +14 -1
  22. package/src/prompts/system-prompt.ts +22 -0
  23. package/src/prompts/templates/NOW.md +26 -0
  24. package/src/prompts/templates/SOUL.md +10 -0
  25. package/src/prompts/update-bulletin-format.ts +0 -2
  26. package/src/runtime/routes/settings-routes.ts +1 -1
  27. package/src/skills/inline-command-expansions.ts +7 -7
  28. package/src/tools/sensitive-output-placeholders.ts +2 -2
  29. package/src/workspace/migrations/015-migrate-credentials-to-keychain.ts +13 -148
  30. package/src/workspace/migrations/016-migrate-credentials-from-keychain.ts +7 -145
  31. package/src/workspace/migrations/AGENTS.md +11 -0
  32. package/src/workspace/migrations/runner.ts +16 -6
  33. package/src/workspace/migrations/types.ts +7 -0
  34. package/src/__tests__/keychain-broker-client.test.ts +0 -800
  35. package/src/security/keychain-broker-client.ts +0 -446
@@ -1,800 +0,0 @@
1
- import { randomBytes } from "node:crypto";
2
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
- import type { Server } from "node:net";
4
- import { createServer } from "node:net";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
7
- import {
8
- afterAll,
9
- afterEach,
10
- beforeAll,
11
- beforeEach,
12
- describe,
13
- expect,
14
- mock,
15
- test,
16
- } from "bun:test";
17
-
18
- // ---------------------------------------------------------------------------
19
- // Mock logger to silence output
20
- // ---------------------------------------------------------------------------
21
-
22
- mock.module("../util/logger.js", () => ({
23
- getLogger: () =>
24
- new Proxy({} as Record<string, unknown>, {
25
- get: () => () => {},
26
- }),
27
- }));
28
-
29
- // ---------------------------------------------------------------------------
30
- // Test fixtures
31
- // ---------------------------------------------------------------------------
32
-
33
- const TEST_DIR = join(
34
- tmpdir(),
35
- `vellum-broker-test-${randomBytes(4).toString("hex")}`,
36
- );
37
- const TOKEN_DIR = join(TEST_DIR, ".vellum", "protected");
38
- const TOKEN_PATH = join(TOKEN_DIR, "keychain-broker.token");
39
- const SOCKET_PATH = join(TEST_DIR, ".vellum", "keychain-broker.sock");
40
- const TEST_TOKEN = "test-auth-token-abc123";
41
-
42
- // ---------------------------------------------------------------------------
43
- // Helpers
44
- // ---------------------------------------------------------------------------
45
-
46
- /**
47
- * Create a mock UDS server that speaks the broker protocol.
48
- * Returns the server and a handler setter for customizing responses.
49
- */
50
- function createMockBroker(): {
51
- server: Server;
52
- setHandler: (
53
- fn: (request: Record<string, unknown>) => Record<string, unknown>,
54
- ) => void;
55
- start: () => Promise<void>;
56
- stop: () => Promise<void>;
57
- } {
58
- let handler: (
59
- request: Record<string, unknown>,
60
- ) => Record<string, unknown> = () => ({ ok: true });
61
-
62
- const connections = new Set<import("node:net").Socket>();
63
-
64
- const server = createServer((conn) => {
65
- connections.add(conn);
66
- conn.on("close", () => connections.delete(conn));
67
- let buffer = "";
68
- conn.on("data", (chunk) => {
69
- buffer += chunk.toString();
70
- let idx: number;
71
- while ((idx = buffer.indexOf("\n")) !== -1) {
72
- const line = buffer.slice(0, idx).trim();
73
- buffer = buffer.slice(idx + 1);
74
- if (!line) continue;
75
- try {
76
- const request = JSON.parse(line);
77
- const response = handler(request);
78
- conn.write(JSON.stringify({ id: request.id, ...response }) + "\n");
79
- } catch {
80
- // Malformed request — ignore
81
- }
82
- }
83
- });
84
- });
85
-
86
- return {
87
- server,
88
- setHandler: (fn) => {
89
- handler = fn;
90
- },
91
- start: () =>
92
- new Promise<void>((resolve) => {
93
- server.listen(SOCKET_PATH, () => resolve());
94
- }),
95
- stop: () =>
96
- new Promise<void>((resolve) => {
97
- // Destroy active connections so server.close() can complete
98
- for (const conn of connections) conn.destroy();
99
- connections.clear();
100
- server.close(() => resolve());
101
- }),
102
- };
103
- }
104
-
105
- // ---------------------------------------------------------------------------
106
- // Setup / teardown
107
- // ---------------------------------------------------------------------------
108
-
109
- beforeAll(() => {
110
- mkdirSync(TOKEN_DIR, { recursive: true });
111
- });
112
-
113
- beforeEach(() => {
114
- // Clean up socket file from prior test
115
- try {
116
- rmSync(SOCKET_PATH, { force: true });
117
- } catch {
118
- /* ignore */
119
- }
120
- });
121
-
122
- afterAll(() => {
123
- rmSync(TEST_DIR, { recursive: true, force: true });
124
- });
125
-
126
- // ---------------------------------------------------------------------------
127
- // Mock platform to point getRootDir at our test directory
128
- // ---------------------------------------------------------------------------
129
-
130
- mock.module("../util/platform.js", () => ({
131
- getRootDir: () => join(TEST_DIR, ".vellum"),
132
- isMacOS: () => true,
133
- getPlatformName: () => "darwin",
134
- }));
135
-
136
- // Import after mocks are set up
137
- const { createBrokerClient } =
138
- await import("../security/keychain-broker-client.js");
139
-
140
- // ---------------------------------------------------------------------------
141
- // Tests
142
- // ---------------------------------------------------------------------------
143
-
144
- describe("keychain-broker-client", () => {
145
- // -----------------------------------------------------------------------
146
- // isAvailable()
147
- // -----------------------------------------------------------------------
148
- describe("isAvailable", () => {
149
- test("returns false when socket file does not exist", () => {
150
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
151
- const client = createBrokerClient();
152
- expect(client.isAvailable()).toBe(false);
153
- });
154
-
155
- test("returns false when token file does not exist", () => {
156
- // Create the socket file so that check passes
157
- writeFileSync(SOCKET_PATH, "");
158
- try {
159
- rmSync(TOKEN_PATH, { force: true });
160
- } catch {
161
- /* ignore */
162
- }
163
- const client = createBrokerClient();
164
- expect(client.isAvailable()).toBe(false);
165
- });
166
-
167
- test("returns true when both socket file and token file exist", () => {
168
- writeFileSync(SOCKET_PATH, "");
169
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
170
- const client = createBrokerClient();
171
- expect(client.isAvailable()).toBe(true);
172
- });
173
- });
174
-
175
- // -----------------------------------------------------------------------
176
- // Request/response serialization
177
- // -----------------------------------------------------------------------
178
- describe("request/response", () => {
179
- let broker: ReturnType<typeof createMockBroker>;
180
-
181
- beforeEach(async () => {
182
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
183
- broker = createMockBroker();
184
- });
185
-
186
- afterEach(async () => {
187
- await broker.stop();
188
- });
189
-
190
- test("ping returns pong from broker", async () => {
191
- broker.setHandler((req) => {
192
- expect(req.v).toBe(1);
193
- if (req.method === "broker.ping") {
194
- return { ok: true, result: { pong: true } };
195
- }
196
- return {
197
- ok: false,
198
- error: { code: "INVALID_REQUEST", message: "unknown method" },
199
- };
200
- });
201
- await broker.start();
202
-
203
- const client = createBrokerClient();
204
- const result = await client.ping();
205
- expect(result).toEqual({ pong: true });
206
- });
207
-
208
- test("get returns found result from broker", async () => {
209
- broker.setHandler((req) => {
210
- expect(req.v).toBe(1);
211
- const params = req.params as { account?: string } | undefined;
212
- if (req.method === "key.get" && params?.account === "my-key") {
213
- return { ok: true, result: { found: true, value: "secret-value" } };
214
- }
215
- return {
216
- ok: false,
217
- error: { code: "INVALID_REQUEST", message: "not found" },
218
- };
219
- });
220
- await broker.start();
221
-
222
- const client = createBrokerClient();
223
- const result = await client.get("my-key");
224
- expect(result).toEqual({ found: true, value: "secret-value" });
225
- });
226
-
227
- test("get returns not-found result from broker", async () => {
228
- broker.setHandler((req) => {
229
- expect(req.v).toBe(1);
230
- if (req.method === "key.get") {
231
- return { ok: true, result: { found: false } };
232
- }
233
- return {
234
- ok: false,
235
- error: { code: "INVALID_REQUEST", message: "bad" },
236
- };
237
- });
238
- await broker.start();
239
-
240
- const client = createBrokerClient();
241
- const result = await client.get("missing-key");
242
- expect(result).toEqual({ found: false, value: undefined });
243
- });
244
-
245
- test("set returns true on success", async () => {
246
- broker.setHandler((req) => {
247
- expect(req.v).toBe(1);
248
- const params = req.params as
249
- | { account?: string; value?: string }
250
- | undefined;
251
- if (
252
- req.method === "key.set" &&
253
- params?.account === "my-key" &&
254
- params?.value === "new-value"
255
- ) {
256
- return { ok: true, result: { stored: true } };
257
- }
258
- return {
259
- ok: false,
260
- error: { code: "INVALID_REQUEST", message: "failed" },
261
- };
262
- });
263
- await broker.start();
264
-
265
- const client = createBrokerClient();
266
- const result = await client.set("my-key", "new-value");
267
- expect(result).toEqual({ status: "ok" });
268
- });
269
-
270
- test("del returns true on success", async () => {
271
- broker.setHandler((req) => {
272
- expect(req.v).toBe(1);
273
- const params = req.params as { account?: string } | undefined;
274
- if (req.method === "key.delete" && params?.account === "my-key") {
275
- return { ok: true, result: { deleted: true } };
276
- }
277
- return {
278
- ok: false,
279
- error: { code: "INVALID_REQUEST", message: "not found" },
280
- };
281
- });
282
- await broker.start();
283
-
284
- const client = createBrokerClient();
285
- const result = await client.del("my-key");
286
- expect(result).toBe(true);
287
- });
288
-
289
- test("list returns account names", async () => {
290
- broker.setHandler((req) => {
291
- expect(req.v).toBe(1);
292
- if (req.method === "key.list") {
293
- return {
294
- ok: true,
295
- result: { accounts: ["key-a", "key-b", "key-c"] },
296
- };
297
- }
298
- return {
299
- ok: false,
300
- error: { code: "INVALID_REQUEST", message: "failed" },
301
- };
302
- });
303
- await broker.start();
304
-
305
- const client = createBrokerClient();
306
- const result = await client.list();
307
- expect(result).toEqual(["key-a", "key-b", "key-c"]);
308
- });
309
-
310
- test("sends auth token and v:1 with every request", async () => {
311
- let receivedToken: unknown;
312
- let receivedVersion: unknown;
313
- broker.setHandler((req) => {
314
- receivedToken = req.token;
315
- receivedVersion = req.v;
316
- return { ok: true, result: { pong: true } };
317
- });
318
- await broker.start();
319
-
320
- const client = createBrokerClient();
321
- await client.ping();
322
- expect(receivedToken).toBe(TEST_TOKEN);
323
- expect(receivedVersion).toBe(1);
324
- });
325
- });
326
-
327
- // -----------------------------------------------------------------------
328
- // Timeout handling
329
- // -----------------------------------------------------------------------
330
- describe("timeout", () => {
331
- let broker: ReturnType<typeof createMockBroker>;
332
-
333
- beforeEach(async () => {
334
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
335
- broker = createMockBroker();
336
- });
337
-
338
- afterEach(async () => {
339
- await broker.stop();
340
- });
341
-
342
- test("returns graceful fallback when broker does not respond within timeout", async () => {
343
- // Handler that never responds
344
- broker.setHandler(() => {
345
- // Intentionally do not return a response — the broker mock won't send anything
346
- return undefined as unknown as Record<string, unknown>;
347
- });
348
-
349
- // Override handler at the server level to swallow requests
350
- broker.server.removeAllListeners("connection");
351
- broker.server.on("connection", (_conn) => {
352
- // Accept connection but never respond
353
- });
354
- await broker.start();
355
-
356
- const client = createBrokerClient();
357
-
358
- // get should return null on timeout (broker error)
359
- const result = await client.get("test-key");
360
- expect(result).toBeNull();
361
- }, 10_000);
362
- });
363
-
364
- // -----------------------------------------------------------------------
365
- // UNAUTHORIZED -> token re-read -> retry
366
- // -----------------------------------------------------------------------
367
- describe("UNAUTHORIZED retry", () => {
368
- let broker: ReturnType<typeof createMockBroker>;
369
-
370
- beforeEach(async () => {
371
- writeFileSync(TOKEN_PATH, "old-token");
372
- broker = createMockBroker();
373
- });
374
-
375
- afterEach(async () => {
376
- await broker.stop();
377
- });
378
-
379
- test("re-reads token and retries on UNAUTHORIZED", async () => {
380
- let callCount = 0;
381
- broker.setHandler((req) => {
382
- callCount++;
383
- if (req.token === "new-token") {
384
- return { ok: true, result: { pong: true } };
385
- }
386
- return {
387
- ok: false,
388
- error: { code: "UNAUTHORIZED", message: "Invalid auth token" },
389
- };
390
- });
391
- await broker.start();
392
-
393
- const client = createBrokerClient();
394
-
395
- // First call will use "old-token" and get UNAUTHORIZED.
396
- // Simulate the token file being updated before the retry.
397
- // We need to update it after the first request but before the retry.
398
- // Since the handler runs synchronously, update the file in the handler.
399
- broker.setHandler((req) => {
400
- callCount++;
401
- if (callCount === 1) {
402
- // First request with old token — write new token file and return UNAUTHORIZED
403
- writeFileSync(TOKEN_PATH, "new-token");
404
- return {
405
- ok: false,
406
- error: { code: "UNAUTHORIZED", message: "Invalid auth token" },
407
- };
408
- }
409
- // Retry with re-read token
410
- if (req.token === "new-token") {
411
- return { ok: true, result: { pong: true } };
412
- }
413
- return {
414
- ok: false,
415
- error: { code: "UNAUTHORIZED", message: "Invalid auth token" },
416
- };
417
- });
418
- callCount = 0;
419
-
420
- const result = await client.ping();
421
- expect(result).toEqual({ pong: true });
422
- expect(callCount).toBe(2);
423
- });
424
- });
425
-
426
- // -----------------------------------------------------------------------
427
- // Graceful degradation
428
- // -----------------------------------------------------------------------
429
- describe("graceful degradation", () => {
430
- test("get returns null when socket file does not exist", async () => {
431
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
432
- const client = createBrokerClient();
433
- const result = await client.get("test-key");
434
- expect(result).toBeNull();
435
- });
436
-
437
- test("set returns unreachable when socket file does not exist", async () => {
438
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
439
- const client = createBrokerClient();
440
- const result = await client.set("test-key", "value");
441
- expect(result).toEqual({ status: "unreachable" });
442
- });
443
-
444
- test("del returns false when socket file does not exist", async () => {
445
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
446
- const client = createBrokerClient();
447
- const result = await client.del("test-key");
448
- expect(result).toBe(false);
449
- });
450
-
451
- test("list returns empty array when socket file does not exist", async () => {
452
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
453
- const client = createBrokerClient();
454
- const result = await client.list();
455
- expect(result).toEqual([]);
456
- });
457
-
458
- test("ping returns null when socket file does not exist", async () => {
459
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
460
- const client = createBrokerClient();
461
- const result = await client.ping();
462
- expect(result).toBeNull();
463
- });
464
-
465
- test("returns fallbacks when token file is missing", async () => {
466
- try {
467
- rmSync(TOKEN_PATH, { force: true });
468
- } catch {
469
- /* ignore */
470
- }
471
- const client = createBrokerClient();
472
- expect(await client.get("key")).toBeNull();
473
- expect(await client.set("key", "val")).toEqual({ status: "unreachable" });
474
- expect(await client.del("key")).toBe(false);
475
- expect(await client.list()).toEqual([]);
476
- expect(await client.ping()).toBeNull();
477
- });
478
- });
479
-
480
- // -----------------------------------------------------------------------
481
- // Connection persistence
482
- // -----------------------------------------------------------------------
483
- describe("connection persistence", () => {
484
- let broker: ReturnType<typeof createMockBroker>;
485
-
486
- beforeEach(async () => {
487
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
488
- broker = createMockBroker();
489
- });
490
-
491
- afterEach(async () => {
492
- await broker.stop();
493
- });
494
-
495
- test("reuses the same connection across multiple requests", async () => {
496
- let connectionCount = 0;
497
- broker.server.on("connection", () => {
498
- connectionCount++;
499
- });
500
- broker.setHandler(() => ({ ok: true, result: { pong: true } }));
501
- await broker.start();
502
-
503
- const client = createBrokerClient();
504
- await client.ping();
505
- await client.ping();
506
- await client.ping();
507
-
508
- expect(connectionCount).toBe(1);
509
- });
510
- });
511
-
512
- // -----------------------------------------------------------------------
513
- // Cooldown-based retry
514
- // -----------------------------------------------------------------------
515
- describe("cooldown-based retry", () => {
516
- const originalDateNow = Date.now;
517
- let fakeNow: number;
518
-
519
- beforeEach(() => {
520
- fakeNow = originalDateNow.call(Date);
521
- Date.now = () => fakeNow;
522
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
523
- });
524
-
525
- afterEach(() => {
526
- Date.now = originalDateNow;
527
- });
528
-
529
- test("retries connection after cooldown period elapses", async () => {
530
- // No socket file — two connection failures (first + immediate retry)
531
- const client = createBrokerClient();
532
- const result = await client.ping();
533
- expect(result).toBeNull();
534
-
535
- // Client should be in cooldown — isAvailable() returns false even with
536
- // socket + token files present.
537
- writeFileSync(SOCKET_PATH, "");
538
- expect(client.isAvailable()).toBe(false);
539
-
540
- // Advance time past the first cooldown (5s)
541
- fakeNow += 5_001;
542
- expect(client.isAvailable()).toBe(true);
543
-
544
- // Now start a real broker and verify the client reconnects
545
- rmSync(SOCKET_PATH, { force: true });
546
- const broker = createMockBroker();
547
- broker.setHandler(() => ({ ok: true, result: { pong: true } }));
548
- await broker.start();
549
-
550
- const retryResult = await client.ping();
551
- expect(retryResult).toEqual({ pong: true });
552
-
553
- await broker.stop();
554
- });
555
-
556
- test("resets failure count after successful reconnection", async () => {
557
- // No socket file — two connection failures
558
- const client = createBrokerClient();
559
- await client.ping();
560
-
561
- // Advance past first cooldown (5s)
562
- fakeNow += 5_001;
563
-
564
- // Start broker — reconnection should succeed and reset counters
565
- const broker = createMockBroker();
566
- broker.setHandler(() => ({ ok: true, result: { pong: true } }));
567
- await broker.start();
568
-
569
- const result = await client.ping();
570
- expect(result).toEqual({ pong: true });
571
-
572
- // Stop broker and remove socket — simulate another disconnection.
573
- // Yield after stop so the client socket receives the close event
574
- // before the next ping (otherwise ensureConnected returns the stale
575
- // socket and sendRequest waits REQUEST_TIMEOUT_MS for a response).
576
- await broker.stop();
577
- await new Promise((r) => setTimeout(r, 50));
578
- rmSync(SOCKET_PATH, { force: true });
579
-
580
- // This new failure should start from the beginning of the cooldown
581
- // schedule (5s), not escalated.
582
- await client.ping();
583
-
584
- // Verify cooldown is back to 5s (not 15s)
585
- writeFileSync(SOCKET_PATH, "");
586
- expect(client.isAvailable()).toBe(false);
587
-
588
- // 5s should be enough to clear cooldown
589
- fakeNow += 5_001;
590
- expect(client.isAvailable()).toBe(true);
591
- }, 15_000);
592
-
593
- test("escalates cooldown on repeated failures", async () => {
594
- const client = createBrokerClient();
595
-
596
- // First failure round: two attempts (first + immediate retry) ->
597
- // consecutiveFailures=2, cooldown index = max(2-2,0) = 0 -> 5s.
598
- await client.ping();
599
-
600
- writeFileSync(SOCKET_PATH, "");
601
- expect(client.isAvailable()).toBe(false);
602
-
603
- // 5s should clear the first cooldown
604
- fakeNow += 5_001;
605
- expect(client.isAvailable()).toBe(true);
606
-
607
- // Remove socket to trigger another failure. After cooldown elapses,
608
- // ensureConnected clears unavailableSince and tries connect().
609
- // This failure increments consecutiveFailures to 3 (no immediate retry
610
- // since consecutiveFailures > 1 after increment).
611
- // Cooldown index = max(3-2,0) = 1 -> 15s.
612
- rmSync(SOCKET_PATH, { force: true });
613
- await client.ping();
614
-
615
- writeFileSync(SOCKET_PATH, "");
616
- expect(client.isAvailable()).toBe(false);
617
-
618
- fakeNow += 5_001;
619
- expect(client.isAvailable()).toBe(false); // 5s not enough
620
-
621
- fakeNow += 10_000; // total 15_001ms since this cooldown started
622
- expect(client.isAvailable()).toBe(true);
623
-
624
- // Another failure -> consecutiveFailures=4, index = max(4-2,0) = 2 -> 30s
625
- rmSync(SOCKET_PATH, { force: true });
626
- await client.ping();
627
-
628
- writeFileSync(SOCKET_PATH, "");
629
- expect(client.isAvailable()).toBe(false);
630
-
631
- fakeNow += 15_001;
632
- expect(client.isAvailable()).toBe(false);
633
-
634
- fakeNow += 15_000; // total 30_001ms
635
- expect(client.isAvailable()).toBe(true);
636
-
637
- // Another failure -> consecutiveFailures=5, index = max(5-2,0) = 3 -> 60s
638
- rmSync(SOCKET_PATH, { force: true });
639
- await client.ping();
640
-
641
- writeFileSync(SOCKET_PATH, "");
642
- expect(client.isAvailable()).toBe(false);
643
-
644
- fakeNow += 30_001;
645
- expect(client.isAvailable()).toBe(false);
646
-
647
- fakeNow += 30_000; // total 60_001ms
648
- expect(client.isAvailable()).toBe(true);
649
-
650
- // Another failure -> consecutiveFailures=6, index = min(max(6-2,0), 4) = 4 -> 300s (5min)
651
- rmSync(SOCKET_PATH, { force: true });
652
- await client.ping();
653
-
654
- writeFileSync(SOCKET_PATH, "");
655
- expect(client.isAvailable()).toBe(false);
656
-
657
- fakeNow += 60_001;
658
- expect(client.isAvailable()).toBe(false);
659
-
660
- fakeNow += 240_000; // total 300_001ms
661
- expect(client.isAvailable()).toBe(true);
662
-
663
- // Another failure -> consecutiveFailures=7, index = min(max(7-2,0), 4) = 4 -> 300s (capped)
664
- rmSync(SOCKET_PATH, { force: true });
665
- await client.ping();
666
-
667
- writeFileSync(SOCKET_PATH, "");
668
- expect(client.isAvailable()).toBe(false);
669
-
670
- fakeNow += 300_001;
671
- expect(client.isAvailable()).toBe(true);
672
- });
673
- });
674
-
675
- // -----------------------------------------------------------------------
676
- // Connect timeout
677
- // -----------------------------------------------------------------------
678
- describe("connect timeout", () => {
679
- let stopFn: (() => Promise<void>) | null = null;
680
-
681
- beforeEach(() => {
682
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
683
- });
684
-
685
- afterEach(async () => {
686
- if (stopFn) {
687
- await stopFn();
688
- stopFn = null;
689
- }
690
- });
691
-
692
- test("rejects connect within 3 seconds when broker is unresponsive", async () => {
693
- // Create a server that accepts connections but never responds
694
- // (simulates an unresponsive broker process).
695
- const activeConns = new Set<import("node:net").Socket>();
696
- const server = createServer((conn) => {
697
- activeConns.add(conn);
698
- conn.on("close", () => activeConns.delete(conn));
699
- // Accept connection but do nothing — no data, no close
700
- });
701
- await new Promise<void>((resolve) => {
702
- server.listen(SOCKET_PATH, () => resolve());
703
- });
704
- stopFn = () =>
705
- new Promise<void>((resolve) => {
706
- for (const conn of activeConns) conn.destroy();
707
- activeConns.clear();
708
- server.close(() => resolve());
709
- });
710
-
711
- const client = createBrokerClient();
712
- const start = Date.now();
713
- const result = await client.ping();
714
- const elapsed = Date.now() - start;
715
-
716
- // Should return null (graceful fallback) and not hang indefinitely.
717
- // The connect timeout is 3s; allow some slack but it should be well
718
- // under 10s (the old behavior would hang for REQUEST_TIMEOUT_MS * retries).
719
- expect(result).toBeNull();
720
- expect(elapsed).toBeLessThan(10_000);
721
- }, 15_000);
722
-
723
- test("successful connect clears the connect timer", async () => {
724
- // Normal broker that responds to pings — verifies the timer is cleared
725
- // and doesn't fire after a successful connection.
726
- const broker = createMockBroker();
727
- broker.setHandler(() => ({ ok: true, result: { pong: true } }));
728
- await broker.start();
729
- stopFn = () => broker.stop();
730
-
731
- const client = createBrokerClient();
732
- const result = await client.ping();
733
- expect(result).toEqual({ pong: true });
734
-
735
- // Wait a bit past the connect timeout to ensure no stale timer fires
736
- await new Promise((r) => setTimeout(r, 100));
737
-
738
- // Client should still work fine
739
- const result2 = await client.ping();
740
- expect(result2).toEqual({ pong: true });
741
- });
742
- });
743
-
744
- // -----------------------------------------------------------------------
745
- // Reduced initial cooldown
746
- // -----------------------------------------------------------------------
747
- describe("reduced initial cooldown", () => {
748
- const originalDateNow = Date.now;
749
- let fakeNow: number;
750
-
751
- beforeEach(() => {
752
- fakeNow = originalDateNow.call(Date);
753
- Date.now = () => fakeNow;
754
- writeFileSync(TOKEN_PATH, TEST_TOKEN);
755
- });
756
-
757
- afterEach(() => {
758
- Date.now = originalDateNow;
759
- });
760
-
761
- test("first cooldown is 5 seconds, not 30 seconds", async () => {
762
- const client = createBrokerClient();
763
-
764
- // Trigger two connection failures (first + immediate retry)
765
- await client.ping();
766
-
767
- writeFileSync(SOCKET_PATH, "");
768
-
769
- // Should still be in cooldown at 4 seconds
770
- fakeNow += 4_000;
771
- expect(client.isAvailable()).toBe(false);
772
-
773
- // Should be available after 5 seconds
774
- fakeNow += 1_001;
775
- expect(client.isAvailable()).toBe(true);
776
- });
777
-
778
- test("second cooldown is 15 seconds", async () => {
779
- const client = createBrokerClient();
780
-
781
- // First failure round -> cooldown 5s
782
- await client.ping();
783
-
784
- // Clear first cooldown
785
- fakeNow += 5_001;
786
-
787
- // Second failure -> cooldown 15s
788
- await client.ping();
789
-
790
- writeFileSync(SOCKET_PATH, "");
791
- expect(client.isAvailable()).toBe(false);
792
-
793
- fakeNow += 14_000;
794
- expect(client.isAvailable()).toBe(false);
795
-
796
- fakeNow += 1_001;
797
- expect(client.isAvailable()).toBe(true);
798
- });
799
- });
800
- });