@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.
- package/ARCHITECTURE.md +70 -13
- package/README.md +6 -0
- package/docs/architecture/http-token-refresh.md +23 -1
- package/package.json +1 -1
- package/src/__tests__/access-request-decision.test.ts +4 -7
- package/src/__tests__/channel-guardian.test.ts +3 -1
- package/src/__tests__/checker.test.ts +79 -48
- package/src/__tests__/config-watcher.test.ts +11 -13
- package/src/__tests__/conversation-pairing.test.ts +103 -3
- package/src/__tests__/guardian-action-conversation-turn.test.ts +1 -1
- package/src/__tests__/guardian-action-followup-executor.test.ts +1 -1
- package/src/__tests__/guardian-action-late-reply.test.ts +131 -0
- package/src/__tests__/guardian-action-store.test.ts +182 -0
- package/src/__tests__/guardian-dispatch.test.ts +120 -0
- package/src/__tests__/ipc-snapshot.test.ts +21 -0
- package/src/__tests__/non-member-access-request.test.ts +1 -2
- package/src/__tests__/notification-broadcaster.test.ts +115 -4
- package/src/__tests__/notification-decision-strategy.test.ts +2 -1
- package/src/__tests__/notification-deep-link.test.ts +44 -1
- package/src/__tests__/notification-guardian-path.test.ts +157 -0
- package/src/__tests__/notification-thread-candidate-validation.test.ts +215 -0
- package/src/__tests__/slack-channel-config.test.ts +3 -3
- package/src/__tests__/trust-store.test.ts +21 -21
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +5 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +2 -6
- package/src/__tests__/trusted-contact-verification.test.ts +9 -9
- package/src/__tests__/update-bulletin-state.test.ts +1 -1
- package/src/__tests__/update-bulletin.test.ts +66 -3
- package/src/__tests__/update-template-contract.test.ts +6 -11
- package/src/__tests__/voice-session-bridge.test.ts +109 -9
- package/src/calls/call-controller.ts +129 -8
- package/src/calls/guardian-action-sweep.ts +1 -1
- package/src/calls/guardian-dispatch.ts +8 -0
- package/src/calls/voice-session-bridge.ts +4 -2
- package/src/cli/core-commands.ts +41 -1
- package/src/config/templates/UPDATES.md +5 -6
- package/src/config/update-bulletin-format.ts +2 -0
- package/src/config/update-bulletin-state.ts +1 -1
- package/src/config/update-bulletin-template-path.ts +6 -0
- package/src/config/update-bulletin.ts +21 -6
- package/src/daemon/config-watcher.ts +3 -2
- package/src/daemon/daemon-control.ts +64 -10
- package/src/daemon/handlers/config-slack-channel.ts +1 -1
- package/src/daemon/handlers/identity.ts +45 -25
- package/src/daemon/handlers/sessions.ts +1 -1
- package/src/daemon/ipc-contract/sessions.ts +1 -1
- package/src/daemon/ipc-contract/workspace.ts +12 -1
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +8 -0
- package/src/daemon/server.ts +25 -3
- package/src/daemon/session-process.ts +438 -184
- package/src/daemon/tls-certs.ts +17 -12
- package/src/daemon/tool-side-effects.ts +1 -1
- package/src/memory/channel-delivery-store.ts +18 -20
- package/src/memory/channel-guardian-store.ts +39 -42
- package/src/memory/conversation-crud.ts +2 -2
- package/src/memory/conversation-queries.ts +2 -2
- package/src/memory/conversation-store.ts +24 -25
- package/src/memory/db-init.ts +9 -1
- package/src/memory/fts-reconciler.ts +41 -26
- package/src/memory/guardian-action-store.ts +57 -7
- package/src/memory/guardian-verification.ts +1 -0
- package/src/memory/jobs-worker.ts +2 -2
- package/src/memory/migrations/032-guardian-delivery-conversation-index.ts +15 -0
- package/src/memory/migrations/032-notification-delivery-thread-decision.ts +20 -0
- package/src/memory/migrations/index.ts +4 -2
- package/src/memory/schema-migration.ts +1 -0
- package/src/memory/schema.ts +6 -1
- package/src/memory/search/semantic.ts +3 -3
- package/src/notifications/README.md +158 -17
- package/src/notifications/broadcaster.ts +68 -50
- package/src/notifications/conversation-pairing.ts +96 -18
- package/src/notifications/decision-engine.ts +6 -3
- package/src/notifications/deliveries-store.ts +12 -0
- package/src/notifications/emit-signal.ts +1 -0
- package/src/notifications/thread-candidates.ts +60 -25
- package/src/notifications/types.ts +2 -1
- package/src/permissions/checker.ts +1 -16
- package/src/permissions/defaults.ts +14 -4
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/http-server.ts +11 -11
- package/src/runtime/routes/access-request-decision.ts +1 -1
- package/src/runtime/routes/debug-routes.ts +4 -4
- package/src/runtime/routes/guardian-approval-interception.ts +4 -4
- package/src/runtime/routes/inbound-message-handler.ts +6 -6
- package/src/runtime/routes/integration-routes.ts +2 -2
- package/src/tools/permission-checker.ts +1 -2
- package/src/tools/secret-detection-handler.ts +1 -1
- package/src/tools/system/voice-config.ts +1 -1
- 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', '
|
|
301
|
-
//
|
|
302
|
-
const match = findMatchingRule('
|
|
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('
|
|
308
|
-
const match = findMatchingRule('
|
|
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('
|
|
328
|
-
const match = findMatchingRule('
|
|
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('
|
|
346
|
-
const match = findMatchingRule('
|
|
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('
|
|
364
|
-
const match = findMatchingRule('
|
|
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('
|
|
379
|
-
expect(findMatchingRule('
|
|
380
|
-
expect(findMatchingRule('
|
|
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
|
|
549
|
-
addRule('
|
|
550
|
-
const match = findMatchingRule('
|
|
548
|
+
// Use host_file_read — it 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
|
|
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:
|
|
813
|
-
expect(match!.decision).toBe('
|
|
814
|
-
expect(match!.priority).toBe(DEFAULT_PRIORITY_BY_ID.get('default:
|
|
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
|
|
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
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
} from '../
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
} from '../
|
|
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,7 +1,9 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { existsSync, mkdirSync,
|
|
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
|
|
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
|
|
6
|
-
*
|
|
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
|
|
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).
|
|
22
|
+
expect(typeof content).toBe('string');
|
|
28
23
|
});
|
|
29
24
|
});
|