@vellumai/assistant 0.3.16 → 0.3.18

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 (90) hide show
  1. package/ARCHITECTURE.md +70 -13
  2. package/README.md +6 -0
  3. package/docs/architecture/http-token-refresh.md +23 -1
  4. package/package.json +1 -1
  5. package/src/__tests__/access-request-decision.test.ts +4 -7
  6. package/src/__tests__/channel-guardian.test.ts +3 -1
  7. package/src/__tests__/checker.test.ts +79 -48
  8. package/src/__tests__/config-watcher.test.ts +11 -13
  9. package/src/__tests__/conversation-pairing.test.ts +103 -3
  10. package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
  11. package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
  12. package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
  13. package/src/__tests__/guardian-action-store.test.ts +182 -0
  14. package/src/__tests__/guardian-dispatch.test.ts +120 -0
  15. package/src/__tests__/ipc-snapshot.test.ts +21 -0
  16. package/src/__tests__/non-member-access-request.test.ts +1 -2
  17. package/src/__tests__/notification-broadcaster.test.ts +115 -4
  18. package/src/__tests__/notification-decision-strategy.test.ts +2 -1
  19. package/src/__tests__/notification-deep-link.test.ts +44 -1
  20. package/src/__tests__/notification-guardian-path.test.ts +157 -0
  21. package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
  22. package/src/__tests__/slack-channel-config.test.ts +3 -3
  23. package/src/__tests__/trust-store.test.ts +21 -21
  24. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
  25. package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
  26. package/src/__tests__/trusted-contact-verification.test.ts +9 -9
  27. package/src/__tests__/update-bulletin-state.test.ts +1 -1
  28. package/src/__tests__/update-bulletin.test.ts +66 -3
  29. package/src/__tests__/update-template-contract.test.ts +6 -11
  30. package/src/__tests__/voice-session-bridge.test.ts +109 -9
  31. package/src/calls/call-controller.ts +129 -8
  32. package/src/calls/guardian-action-sweep.ts +1 -1
  33. package/src/calls/guardian-dispatch.ts +8 -0
  34. package/src/calls/voice-session-bridge.ts +4 -2
  35. package/src/cli/core-commands.ts +41 -1
  36. package/src/config/templates/UPDATES.md +5 -6
  37. package/src/config/update-bulletin-format.ts +2 -0
  38. package/src/config/update-bulletin-state.ts +1 -1
  39. package/src/config/update-bulletin-template-path.ts +6 -0
  40. package/src/config/update-bulletin.ts +21 -6
  41. package/src/daemon/config-watcher.ts +3 -2
  42. package/src/daemon/daemon-control.ts +64 -10
  43. package/src/daemon/handlers/config-slack-channel.ts +1 -1
  44. package/src/daemon/handlers/identity.ts +45 -25
  45. package/src/daemon/handlers/sessions.ts +1 -1
  46. package/src/daemon/ipc-contract/sessions.ts +1 -1
  47. package/src/daemon/ipc-contract/workspace.ts +12 -1
  48. package/src/daemon/ipc-contract-inventory.json +1 -0
  49. package/src/daemon/lifecycle.ts +8 -0
  50. package/src/daemon/server.ts +25 -3
  51. package/src/daemon/session-process.ts +438 -184
  52. package/src/daemon/tls-certs.ts +17 -12
  53. package/src/daemon/tool-side-effects.ts +1 -1
  54. package/src/memory/channel-delivery-store.ts +18 -20
  55. package/src/memory/channel-guardian-store.ts +39 -42
  56. package/src/memory/conversation-crud.ts +2 -2
  57. package/src/memory/conversation-queries.ts +2 -2
  58. package/src/memory/conversation-store.ts +24 -25
  59. package/src/memory/db-init.ts +9 -1
  60. package/src/memory/fts-reconciler.ts +41 -26
  61. package/src/memory/guardian-action-store.ts +57 -7
  62. package/src/memory/guardian-verification.ts +1 -0
  63. package/src/memory/jobs-worker.ts +2 -2
  64. package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
  65. package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
  66. package/src/memory/migrations/index.ts +4 -2
  67. package/src/memory/schema-migration.ts +1 -0
  68. package/src/memory/schema.ts +6 -1
  69. package/src/memory/search/semantic.ts +3 -3
  70. package/src/notifications/README.md +158 -17
  71. package/src/notifications/broadcaster.ts +68 -50
  72. package/src/notifications/conversation-pairing.ts +96 -18
  73. package/src/notifications/decision-engine.ts +6 -3
  74. package/src/notifications/deliveries-store.ts +12 -0
  75. package/src/notifications/emit-signal.ts +1 -0
  76. package/src/notifications/thread-candidates.ts +60 -25
  77. package/src/notifications/types.ts +2 -1
  78. package/src/permissions/checker.ts +1 -16
  79. package/src/permissions/defaults.ts +14 -4
  80. package/src/runtime/guardian-action-followup-executor.ts +1 -1
  81. package/src/runtime/http-server.ts +11 -11
  82. package/src/runtime/routes/access-request-decision.ts +1 -1
  83. package/src/runtime/routes/debug-routes.ts +4 -4
  84. package/src/runtime/routes/guardian-approval-interception.ts +4 -4
  85. package/src/runtime/routes/inbound-message-handler.ts +6 -6
  86. package/src/runtime/routes/integration-routes.ts +2 -2
  87. package/src/tools/permission-checker.ts +1 -2
  88. package/src/tools/secret-detection-handler.ts +1 -1
  89. package/src/tools/system/voice-config.ts +1 -1
  90. package/src/version.ts +29 -2
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Focused tests for thread candidate validation in the notification decision
3
+ * engine. Validates that:
4
+ * - Valid reuse targets pass validation
5
+ * - Invalid reuse targets are rejected and downgraded to start_new
6
+ * - Candidate context is structurally correct and auditable
7
+ */
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import { validateThreadActions } from '../notifications/decision-engine.js';
12
+ import type {
13
+ ThreadCandidate,
14
+ ThreadCandidateSet,
15
+ } from '../notifications/thread-candidates.js';
16
+ import type {
17
+ NotificationChannel,
18
+ ThreadAction,
19
+ } from '../notifications/types.js';
20
+
21
+ // -- Helpers -----------------------------------------------------------------
22
+
23
+ function makeCandidate(overrides?: Partial<ThreadCandidate>): ThreadCandidate {
24
+ return {
25
+ conversationId: 'conv-default',
26
+ title: 'Test Thread',
27
+ updatedAt: Date.now(),
28
+ latestSourceEventName: 'test.event',
29
+ channel: 'vellum' as NotificationChannel,
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ /**
35
+ * Simple candidate ID check equivalent to the removed isValidCandidateId.
36
+ * Used in tests to verify candidate matching semantics.
37
+ */
38
+ function isCandidateIdPresent(id: string, candidates: ThreadCandidate[]): boolean {
39
+ return candidates.some((c) => c.conversationId === id);
40
+ }
41
+
42
+ // -- Tests -------------------------------------------------------------------
43
+
44
+ describe('thread candidate validation', () => {
45
+ describe('candidate ID matching', () => {
46
+ test('returns true when conversationId matches a candidate', () => {
47
+ const candidates = [
48
+ makeCandidate({ conversationId: 'conv-001' }),
49
+ makeCandidate({ conversationId: 'conv-002' }),
50
+ ];
51
+
52
+ expect(isCandidateIdPresent('conv-001', candidates)).toBe(true);
53
+ expect(isCandidateIdPresent('conv-002', candidates)).toBe(true);
54
+ });
55
+
56
+ test('returns false when conversationId does not match any candidate', () => {
57
+ const candidates = [
58
+ makeCandidate({ conversationId: 'conv-001' }),
59
+ ];
60
+
61
+ expect(isCandidateIdPresent('conv-999', candidates)).toBe(false);
62
+ });
63
+
64
+ test('returns false for empty candidate list', () => {
65
+ expect(isCandidateIdPresent('conv-001', [])).toBe(false);
66
+ });
67
+
68
+ test('returns false for empty string conversationId', () => {
69
+ const candidates = [
70
+ makeCandidate({ conversationId: 'conv-001' }),
71
+ ];
72
+
73
+ expect(isCandidateIdPresent('', candidates)).toBe(false);
74
+ });
75
+
76
+ test('matching is exact (no substring or prefix matching)', () => {
77
+ const candidates = [
78
+ makeCandidate({ conversationId: 'conv-001' }),
79
+ ];
80
+
81
+ expect(isCandidateIdPresent('conv-00', candidates)).toBe(false);
82
+ expect(isCandidateIdPresent('conv-0011', candidates)).toBe(false);
83
+ expect(isCandidateIdPresent('CONV-001', candidates)).toBe(false);
84
+ });
85
+ });
86
+
87
+ describe('candidate metadata structure', () => {
88
+ test('candidate without guardian context has no optional fields', () => {
89
+ const candidate = makeCandidate();
90
+
91
+ expect(candidate.guardianContext).toBeUndefined();
92
+ });
93
+
94
+ test('candidate with guardian context includes pending counts', () => {
95
+ const candidate = makeCandidate({
96
+ guardianContext: { pendingUnresolvedRequestCount: 3 },
97
+ });
98
+
99
+ expect(candidate.guardianContext?.pendingUnresolvedRequestCount).toBe(3);
100
+ });
101
+
102
+ test('candidate with null title is valid', () => {
103
+ const candidate = makeCandidate({ title: null });
104
+ expect(candidate.title).toBeNull();
105
+ });
106
+
107
+ test('candidate with null latestSourceEventName is valid', () => {
108
+ const candidate = makeCandidate({ latestSourceEventName: null });
109
+ expect(candidate.latestSourceEventName).toBeNull();
110
+ });
111
+ });
112
+
113
+ describe('thread action downgrade semantics', () => {
114
+ test('start_new action does not require a conversationId', () => {
115
+ const action: ThreadAction = { action: 'start_new' };
116
+ expect(action.action).toBe('start_new');
117
+ expect('conversationId' in action).toBe(false);
118
+ });
119
+
120
+ test('reuse_existing with valid candidate is accepted via validateThreadActions', () => {
121
+ const candidateSet: ThreadCandidateSet = {
122
+ vellum: [makeCandidate({ conversationId: 'conv-valid' })],
123
+ };
124
+
125
+ const result = validateThreadActions(
126
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-valid' } },
127
+ ['vellum'] as NotificationChannel[],
128
+ candidateSet,
129
+ );
130
+
131
+ expect(result.vellum?.action).toBe('reuse_existing');
132
+ if (result.vellum?.action === 'reuse_existing') {
133
+ expect(result.vellum.conversationId).toBe('conv-valid');
134
+ }
135
+ });
136
+
137
+ test('reuse_existing with invalid candidate is downgraded to start_new', () => {
138
+ const candidateSet: ThreadCandidateSet = {
139
+ vellum: [makeCandidate({ conversationId: 'conv-valid' })],
140
+ };
141
+
142
+ const result = validateThreadActions(
143
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-hacked' } },
144
+ ['vellum'] as NotificationChannel[],
145
+ candidateSet,
146
+ );
147
+
148
+ expect(result.vellum?.action).toBe('start_new');
149
+ });
150
+
151
+ test('reuse_existing with empty candidate set is downgraded to start_new', () => {
152
+ const result = validateThreadActions(
153
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-any' } },
154
+ ['vellum'] as NotificationChannel[],
155
+ undefined,
156
+ );
157
+
158
+ expect(result.vellum?.action).toBe('start_new');
159
+ });
160
+ });
161
+
162
+ describe('candidate set per channel', () => {
163
+ test('channels without candidates result in empty map entries', () => {
164
+ const candidateMap: ThreadCandidateSet = {};
165
+
166
+ // When no candidates exist for vellum, the map has no entry
167
+ expect(candidateMap.vellum).toBeUndefined();
168
+ });
169
+
170
+ test('candidate set preserves channel association via validateThreadActions', () => {
171
+ const vellumCandidates = [
172
+ makeCandidate({ conversationId: 'conv-v1', channel: 'vellum' as NotificationChannel }),
173
+ ];
174
+ const telegramCandidates = [
175
+ makeCandidate({ conversationId: 'conv-t1', channel: 'telegram' as NotificationChannel }),
176
+ ];
177
+
178
+ const candidateSet: ThreadCandidateSet = {
179
+ vellum: vellumCandidates,
180
+ telegram: telegramCandidates,
181
+ };
182
+
183
+ // Vellum candidate should not be valid for telegram and vice versa
184
+ const validChannels: NotificationChannel[] = ['vellum', 'telegram'];
185
+
186
+ const result1 = validateThreadActions(
187
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-v1' } },
188
+ validChannels,
189
+ candidateSet,
190
+ );
191
+ expect(result1.vellum?.action).toBe('reuse_existing');
192
+
193
+ const result2 = validateThreadActions(
194
+ { vellum: { action: 'reuse_existing', conversationId: 'conv-t1' } },
195
+ validChannels,
196
+ candidateSet,
197
+ );
198
+ expect(result2.vellum?.action).toBe('start_new');
199
+
200
+ const result3 = validateThreadActions(
201
+ { telegram: { action: 'reuse_existing', conversationId: 'conv-t1' } },
202
+ validChannels,
203
+ candidateSet,
204
+ );
205
+ expect(result3.telegram?.action).toBe('reuse_existing');
206
+
207
+ const result4 = validateThreadActions(
208
+ { telegram: { action: 'reuse_existing', conversationId: 'conv-v1' } },
209
+ validChannels,
210
+ candidateSet,
211
+ );
212
+ expect(result4.telegram?.action).toBe('start_new');
213
+ });
214
+ });
215
+ });
@@ -105,9 +105,9 @@ mock.module('../tools/credentials/metadata-store.js', () => ({
105
105
  const originalFetch = globalThis.fetch;
106
106
 
107
107
  import {
108
+ clearSlackChannelConfig,
108
109
  getSlackChannelConfig,
109
110
  setSlackChannelConfig,
110
- clearSlackChannelConfig,
111
111
  } from '../daemon/handlers/config-slack-channel.js';
112
112
 
113
113
  afterAll(() => {
@@ -186,7 +186,7 @@ describe('Slack channel config handler', () => {
186
186
  status: 200,
187
187
  headers: { 'content-type': 'application/json' },
188
188
  });
189
- }) as typeof globalThis.fetch;
189
+ }) as unknown as typeof globalThis.fetch;
190
190
 
191
191
  const result = await setSlackChannelConfig('xoxb-valid-bot-token');
192
192
  expect(result.success).toBe(true);
@@ -204,7 +204,7 @@ describe('Slack channel config handler', () => {
204
204
  status: 200,
205
205
  headers: { 'content-type': 'application/json' },
206
206
  });
207
- }) as typeof globalThis.fetch;
207
+ }) as unknown as typeof globalThis.fetch;
208
208
 
209
209
  const result = await setSlackChannelConfig('xoxb-bad-token');
210
210
  expect(result.success).toBe(false);
@@ -297,15 +297,15 @@ describe('Trust Store', () => {
297
297
  });
298
298
 
299
299
  test('returns null when tool does not match', () => {
300
- addRule('file_write', 'git *', '/tmp');
301
- // host_bash default is 'ask' so findMatchingRule (allow-only) won't find it
302
- const match = findMatchingRule('host_bash', 'git push', '/tmp');
300
+ addRule('file_write', 'file_write:/tmp/*', '/tmp');
301
+ // host_file_read default is 'ask' so findMatchingRule (allow-only) won't find it
302
+ const match = findMatchingRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
303
303
  expect(match).toBeNull();
304
304
  });
305
305
 
306
306
  test('returns null when pattern does not match', () => {
307
- addRule('host_bash', 'git *', '/tmp');
308
- const match = findMatchingRule('host_bash', 'npm install', '/tmp');
307
+ addRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
308
+ const match = findMatchingRule('host_file_read', 'host_file_read:/var/log/syslog', '/tmp');
309
309
  expect(match).toBeNull();
310
310
  });
311
311
 
@@ -324,8 +324,8 @@ describe('Trust Store', () => {
324
324
  });
325
325
 
326
326
  test('does not match when scope is outside rule scope', () => {
327
- addRule('host_bash', 'npm *', '/home/user/project');
328
- const match = findMatchingRule('host_bash', 'npm install', '/home/other');
327
+ addRule('host_file_read', 'host_file_read:/home/user/project/*', '/home/user/project');
328
+ const match = findMatchingRule('host_file_read', 'host_file_read:/home/user/project/file.txt', '/home/other');
329
329
  expect(match).toBeNull();
330
330
  });
331
331
 
@@ -342,8 +342,8 @@ describe('Trust Store', () => {
342
342
  });
343
343
 
344
344
  test('does not match sibling path with shared prefix', () => {
345
- addRule('host_bash', 'npm *', '/home/user/project');
346
- const match = findMatchingRule('host_bash', 'npm install', '/home/user/project-evil');
345
+ addRule('host_file_read', 'host_file_read:/home/user/project/*', '/home/user/project');
346
+ const match = findMatchingRule('host_file_read', 'host_file_read:/home/user/project/file.txt', '/home/user/project-evil');
347
347
  expect(match).toBeNull();
348
348
  });
349
349
 
@@ -360,8 +360,8 @@ describe('Trust Store', () => {
360
360
  });
361
361
 
362
362
  test('does not match sibling with glob-suffixed scope', () => {
363
- addRule('host_bash', 'npm *', '/home/user/project*');
364
- const match = findMatchingRule('host_bash', 'npm install', '/home/user/project-evil');
363
+ addRule('host_file_read', 'host_file_read:/home/user/project/*', '/home/user/project*');
364
+ const match = findMatchingRule('host_file_read', 'host_file_read:/home/user/project/file.txt', '/home/user/project-evil');
365
365
  expect(match).toBeNull();
366
366
  });
367
367
  });
@@ -375,9 +375,9 @@ describe('Trust Store', () => {
375
375
  });
376
376
 
377
377
  test('matches exact string', () => {
378
- addRule('host_bash', 'git status', '/tmp');
379
- expect(findMatchingRule('host_bash', 'git status', '/tmp')).not.toBeNull();
380
- expect(findMatchingRule('host_bash', 'git push', '/tmp')).toBeNull();
378
+ addRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
379
+ expect(findMatchingRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp')).not.toBeNull();
380
+ expect(findMatchingRule('host_file_read', 'host_file_read:/etc/passwd', '/tmp')).toBeNull();
381
381
  });
382
382
 
383
383
  test('matches file path pattern', () => {
@@ -545,9 +545,9 @@ describe('Trust Store', () => {
545
545
  });
546
546
 
547
547
  test('findMatchingRule ignores deny rules', () => {
548
- // Use host_bashbash has a default allow rule that would match.
549
- addRule('host_bash', 'rm *', '/tmp', 'deny');
550
- const match = findMatchingRule('host_bash', 'rm file.txt', '/tmp');
548
+ // Use host_file_readit has an 'ask' default so findMatchingRule (allow-only) won't find it.
549
+ addRule('host_file_read', 'host_file_read:/etc/*', '/tmp', 'deny');
550
+ const match = findMatchingRule('host_file_read', 'host_file_read:/etc/hosts', '/tmp');
551
551
  expect(match).toBeNull();
552
552
  });
553
553
 
@@ -806,12 +806,12 @@ describe('Trust Store', () => {
806
806
  expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_file_edit-global')!);
807
807
  });
808
808
 
809
- test('findHighestPriorityRule matches default ask for host_bash', () => {
809
+ test('findHighestPriorityRule matches default allow for host_bash', () => {
810
810
  const match = findHighestPriorityRule('host_bash', ['ls'], '/tmp');
811
811
  expect(match).not.toBeNull();
812
- expect(match!.id).toBe('default:ask-host_bash-global');
813
- expect(match!.decision).toBe('ask');
814
- expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:ask-host_bash-global')!);
812
+ expect(match!.id).toBe('default:allow-host_bash-global');
813
+ expect(match!.decision).toBe('allow');
814
+ expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:allow-host_bash-global')!);
815
815
  });
816
816
 
817
817
  test('findHighestPriorityRule matches default ask for computer_use_click', () => {
@@ -85,14 +85,12 @@ mock.module('../runtime/approval-message-composer.js', () => ({
85
85
  import {
86
86
  createApprovalRequest,
87
87
  createBinding,
88
- findPendingAccessRequestForRequester,
89
- getAllPendingApprovalsByGuardianChat,
90
88
  } from '../memory/channel-guardian-store.js';
89
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
90
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
91
91
  import {
92
92
  createOutboundSession,
93
93
  } from '../runtime/channel-guardian-service.js';
94
- import { findMember, upsertMember } from '../memory/ingress-member-store.js';
95
- import { initializeDb, resetDb } from '../memory/db.js';
96
94
  import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
97
95
 
98
96
  initializeDb();
@@ -110,7 +108,6 @@ const TEST_BEARER_TOKEN = 'test-token';
110
108
  const GUARDIAN_APPROVAL_TTL_MS = 5 * 60 * 1000;
111
109
 
112
110
  function resetState(): void {
113
- const { getDb } = require('../memory/db.js');
114
111
  const db = getDb();
115
112
  db.run('DELETE FROM channel_guardian_approval_requests');
116
113
  db.run('DELETE FROM channel_guardian_bindings');
@@ -177,7 +174,7 @@ describe('trusted contact lifecycle notification signals', () => {
177
174
  const testRequestId = `req-deny-${Date.now()}`;
178
175
 
179
176
  // Create a pending access request approval
180
- const approval = createApprovalRequest({
177
+ const _approval = createApprovalRequest({
181
178
  runId: `ingress-access-request-${Date.now()}`,
182
179
  requestId: testRequestId,
183
180
  conversationId: 'access-req-telegram-requester-user-456',
@@ -252,7 +249,7 @@ describe('trusted contact lifecycle notification signals', () => {
252
249
  const testRequestId = `req-approve-${Date.now()}`;
253
250
 
254
251
  // Create a pending access request approval
255
- const approval = createApprovalRequest({
252
+ const _approval = createApprovalRequest({
256
253
  runId: `ingress-access-request-${Date.now()}`,
257
254
  requestId: testRequestId,
258
255
  conversationId: 'access-req-telegram-requester-user-456',
@@ -426,6 +423,7 @@ describe('trusted contact activated notification signal', () => {
426
423
 
427
424
  test('guardian verification does NOT emit activated signal', async () => {
428
425
  // Create an inbound challenge (guardian flow, not trusted contact)
426
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
429
427
  const { createVerificationChallenge } = require('../runtime/channel-guardian-service.js');
430
428
  const { secret } = createVerificationChallenge('self', 'telegram');
431
429
 
@@ -77,16 +77,13 @@ import {
77
77
  createBinding,
78
78
  findPendingAccessRequestForRequester,
79
79
  } from '../memory/channel-guardian-store.js';
80
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
81
+ import { findMember, upsertMember } from '../memory/ingress-member-store.js';
80
82
  import {
81
83
  createOutboundSession,
82
84
  validateAndConsumeChallenge,
83
85
  } from '../runtime/channel-guardian-service.js';
84
- import { findMember, upsertMember } from '../memory/ingress-member-store.js';
85
- import { initializeDb, resetDb } from '../memory/db.js';
86
86
  import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
87
- import {
88
- handleAccessRequestDecision,
89
- } from '../runtime/routes/access-request-decision.js';
90
87
 
91
88
  initializeDb();
92
89
 
@@ -102,7 +99,6 @@ afterAll(() => {
102
99
  const TEST_BEARER_TOKEN = 'test-token';
103
100
 
104
101
  function resetState(): void {
105
- const { getDb } = require('../memory/db.js');
106
102
  const db = getDb();
107
103
  db.run('DELETE FROM channel_guardian_approval_requests');
108
104
  db.run('DELETE FROM channel_guardian_bindings');
@@ -42,20 +42,20 @@ mock.module('../util/logger.js', () => ({
42
42
  }),
43
43
  }));
44
44
 
45
- import { initializeDb, resetDb } from '../memory/db.js';
46
45
  import {
47
- createOutboundSession,
48
- validateAndConsumeChallenge,
49
- } from '../runtime/channel-guardian-service.js';
46
+ createBinding,
47
+ getActiveBinding,
48
+ } from '../memory/channel-guardian-store.js';
49
+ import { getDb, initializeDb, resetDb } from '../memory/db.js';
50
50
  import {
51
51
  findMember,
52
- upsertMember,
53
52
  revokeMember,
53
+ upsertMember,
54
54
  } from '../memory/ingress-member-store.js';
55
55
  import {
56
- getActiveBinding,
57
- createBinding,
58
- } from '../memory/channel-guardian-store.js';
56
+ createOutboundSession,
57
+ validateAndConsumeChallenge,
58
+ } from '../runtime/channel-guardian-service.js';
59
59
 
60
60
  initializeDb();
61
61
 
@@ -69,7 +69,6 @@ afterAll(() => {
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
71
  function resetTables(): void {
72
- const { getDb } = require('../memory/db.js');
73
72
  const db = getDb();
74
73
  db.run('DELETE FROM channel_guardian_verification_challenges');
75
74
  db.run('DELETE FROM channel_guardian_bindings');
@@ -339,6 +338,7 @@ describe('trusted contact verification → member activation', () => {
339
338
 
340
339
  test('guardian inbound verification still creates binding (backward compat)', () => {
341
340
  // Create an inbound challenge (no expected identity — guardian flow)
341
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
342
342
  const { createVerificationChallenge } = require('../runtime/channel-guardian-service.js');
343
343
  const { secret } = createVerificationChallenge('self', 'telegram');
344
344
 
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, beforeEach, mock } from 'bun:test';
1
+ import { beforeEach, describe, expect, it, mock } from 'bun:test';
2
2
 
3
3
  const store = new Map<string, string>();
4
4
 
@@ -1,7 +1,9 @@
1
- import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test';
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
1
+ import * as fs from 'node:fs';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
3
  import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
5
7
 
6
8
  // --- In-memory checkpoint store ---
7
9
  const store = new Map<string, string>();
@@ -14,6 +16,12 @@ mock.module('../memory/checkpoints.js', () => ({
14
16
  // --- Temp directory for workspace paths ---
15
17
  let tempDir: string;
16
18
 
19
+ // --- Temp directory for template files ---
20
+ // Avoids mutating the real source-controlled UPDATES.md template, preventing
21
+ // race conditions with parallel test execution and working tree corruption
22
+ // if the test process crashes.
23
+ let tempTemplateDir: string;
24
+
17
25
  // Mock platform to avoid env-registry transitive imports.
18
26
  // All needed exports are stubbed; getWorkspacePromptPath is the only one
19
27
  // exercised by update-bulletin.ts.
@@ -92,17 +100,31 @@ mock.module('../version.js', () => ({
92
100
  APP_VERSION: '1.0.0',
93
101
  }));
94
102
 
103
+ // Mock the template path module so tests read from a temp directory instead
104
+ // of the real source-controlled template file.
105
+ mock.module('../config/update-bulletin-template-path.js', () => ({
106
+ getTemplatePath: () => join(tempTemplateDir, 'UPDATES.md'),
107
+ }));
108
+
95
109
  const { syncUpdateBulletinOnStartup } = await import('../config/update-bulletin.js');
96
110
 
111
+ const TEST_TEMPLATE = '## What\'s New\n\nTest release notes.\n';
112
+ const COMMENT_ONLY_TEMPLATE = '_ This is a comment-only template.\n_ No real content here.\n';
113
+
97
114
  describe('syncUpdateBulletinOnStartup', () => {
98
115
  beforeEach(() => {
99
116
  store.clear();
100
117
  tempDir = join(tmpdir(), `update-bulletin-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
101
118
  mkdirSync(tempDir, { recursive: true });
119
+ tempTemplateDir = join(tmpdir(), `update-bulletin-tpl-${Date.now()}-${Math.random().toString(36).slice(2)}`);
120
+ mkdirSync(tempTemplateDir, { recursive: true });
121
+ // Write a test template with real content so materialization proceeds
122
+ writeFileSync(join(tempTemplateDir, 'UPDATES.md'), TEST_TEMPLATE, 'utf-8');
102
123
  });
103
124
 
104
125
  afterEach(() => {
105
126
  rmSync(tempDir, { recursive: true, force: true });
127
+ rmSync(tempTemplateDir, { recursive: true, force: true });
106
128
  });
107
129
 
108
130
  it('creates workspace file on first eligible run', () => {
@@ -257,4 +279,45 @@ describe('syncUpdateBulletinOnStartup', () => {
257
279
  const tmpFiles = entries.filter((e) => e.includes('.tmp.'));
258
280
  expect(tmpFiles).toHaveLength(0);
259
281
  });
282
+
283
+ it('skips materialization when template is comment-only', () => {
284
+ // Write a comment-only template fixture (no real content after stripping)
285
+ writeFileSync(join(tempTemplateDir, 'UPDATES.md'), COMMENT_ONLY_TEMPLATE, 'utf-8');
286
+
287
+ const workspacePath = join(tempDir, 'UPDATES.md');
288
+ syncUpdateBulletinOnStartup();
289
+
290
+ expect(existsSync(workspacePath)).toBe(false);
291
+ });
292
+
293
+ it('preserves existing file when atomic write fails', () => {
294
+ const workspacePath = join(tempDir, 'UPDATES.md');
295
+ const originalContent = '<!-- vellum-update-release:0.9.0 -->\nOriginal content.\n';
296
+ writeFileSync(workspacePath, originalContent, 'utf-8');
297
+
298
+ // Mock writeFileSync to throw when writing the temp file, simulating a
299
+ // disk-full or permission error deterministically (chmod-based approaches
300
+ // are unreliable when running as root or with CAP_DAC_OVERRIDE).
301
+ const originalWriteFileSync = fs.writeFileSync;
302
+ const spy = spyOn(fs, 'writeFileSync').mockImplementation((...args: Parameters<typeof fs.writeFileSync>) => {
303
+ if (typeof args[0] === 'string' && args[0].includes('.tmp.')) {
304
+ throw new Error('Simulated write failure');
305
+ }
306
+ return originalWriteFileSync(...args);
307
+ });
308
+ try {
309
+ expect(() => syncUpdateBulletinOnStartup()).toThrow('Simulated write failure');
310
+ } finally {
311
+ spy.mockRestore();
312
+ }
313
+
314
+ // Original content should be preserved (atomic write never renamed over it)
315
+ const content = readFileSync(workspacePath, 'utf-8');
316
+ expect(content).toBe(originalContent);
317
+
318
+ // No temp file leftovers
319
+ const entries = readdirSync(tempDir);
320
+ const tmpFiles = entries.filter((e: string) => e.includes('.tmp.'));
321
+ expect(tmpFiles).toHaveLength(0);
322
+ });
260
323
  });
@@ -1,13 +1,13 @@
1
1
  /**
2
- * Contract test: ensures the bundled UPDATES.md template exists and meets
3
- * the format expectations that the bulletin system depends on at runtime.
2
+ * Contract test: ensures the bundled UPDATES.md template exists and is readable.
4
3
  *
5
- * The "## What's New" heading is a structural contract bulletin rendering
6
- * logic expects this section to be present in the template.
4
+ * The template may be comment-only (no real content) for no-op releases
5
+ * the bulletin system treats an empty-after-stripping template as a skip signal.
7
6
  */
8
7
 
9
8
  import { existsSync, readFileSync } from 'node:fs';
10
9
  import { join } from 'node:path';
10
+
11
11
  import { describe, expect, test } from 'bun:test';
12
12
 
13
13
  const TEMPLATE_PATH = join(import.meta.dirname, '..', 'config', 'templates', 'UPDATES.md');
@@ -17,13 +17,8 @@ describe('UPDATES.md template contract', () => {
17
17
  expect(existsSync(TEMPLATE_PATH)).toBe(true);
18
18
  });
19
19
 
20
- test('template contains non-whitespace content', () => {
21
- const content = readFileSync(TEMPLATE_PATH, 'utf-8');
22
- expect(content.trim().length).toBeGreaterThan(0);
23
- });
24
-
25
- test('template contains the "## What\'s New" heading', () => {
20
+ test('template is a readable UTF-8 file', () => {
26
21
  const content = readFileSync(TEMPLATE_PATH, 'utf-8');
27
- expect(content).toContain("## What's New");
22
+ expect(typeof content).toBe('string');
28
23
  });
29
24
  });