@vellumai/assistant 0.4.13 → 0.4.15

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 (133) hide show
  1. package/ARCHITECTURE.md +77 -38
  2. package/README.md +10 -12
  3. package/package.json +1 -1
  4. package/src/__tests__/actor-token-service.test.ts +108 -522
  5. package/src/__tests__/channel-approval-routes.test.ts +92 -239
  6. package/src/__tests__/channel-approval.test.ts +100 -0
  7. package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
  8. package/src/__tests__/conversation-routes.test.ts +11 -4
  9. package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
  10. package/src/__tests__/mcp-health-check.test.ts +65 -0
  11. package/src/__tests__/permission-types.test.ts +33 -0
  12. package/src/__tests__/scan-result-store.test.ts +121 -0
  13. package/src/__tests__/session-agent-loop.test.ts +120 -0
  14. package/src/__tests__/session-approval-overrides.test.ts +205 -0
  15. package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
  16. package/src/amazon/client.ts +8 -5
  17. package/src/approvals/guardian-decision-primitive.ts +14 -9
  18. package/src/approvals/guardian-request-resolvers.ts +2 -2
  19. package/src/calls/call-controller.ts +2 -2
  20. package/src/calls/twilio-routes.ts +2 -2
  21. package/src/cli/mcp.ts +3 -3
  22. package/src/cli.ts +24 -0
  23. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
  24. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
  25. package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
  26. package/src/config/bundled-skills/messaging/SKILL.md +49 -14
  27. package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
  28. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
  29. package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
  30. package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
  31. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
  32. package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
  33. package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
  34. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
  35. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
  36. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
  37. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
  38. package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
  39. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  40. package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
  41. package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
  42. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
  43. package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
  44. package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
  45. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
  46. package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
  47. package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
  48. package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
  49. package/src/daemon/approval-generators.ts +6 -3
  50. package/src/daemon/handlers/config-ingress.ts +2 -6
  51. package/src/daemon/handlers/guardian-actions.ts +1 -1
  52. package/src/daemon/handlers/sessions.ts +4 -1
  53. package/src/daemon/handlers/shared.ts +3 -0
  54. package/src/daemon/handlers/skills.ts +32 -0
  55. package/src/daemon/ipc-contract/messages.ts +3 -1
  56. package/src/daemon/ipc-handler.ts +24 -0
  57. package/src/daemon/ipc-validate.ts +1 -1
  58. package/src/daemon/lifecycle.ts +6 -8
  59. package/src/daemon/server.ts +8 -3
  60. package/src/daemon/session-agent-loop.ts +19 -1
  61. package/src/daemon/session-attachments.ts +2 -1
  62. package/src/daemon/session-history.ts +2 -2
  63. package/src/daemon/session-process.ts +5 -9
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session-tool-setup.ts +216 -69
  66. package/src/daemon/session.ts +24 -1
  67. package/src/events/domain-events.ts +1 -1
  68. package/src/events/tool-domain-event-publisher.ts +5 -10
  69. package/src/influencer/client.ts +8 -7
  70. package/src/messaging/providers/gmail/client.ts +33 -1
  71. package/src/messaging/providers/gmail/mime-builder.ts +5 -1
  72. package/src/messaging/providers/sms/adapter.ts +3 -7
  73. package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
  74. package/src/messaging/providers/whatsapp/adapter.ts +3 -7
  75. package/src/notifications/adapters/sms.ts +2 -2
  76. package/src/notifications/adapters/telegram.ts +2 -2
  77. package/src/permissions/prompter.ts +2 -0
  78. package/src/permissions/types.ts +11 -1
  79. package/src/runtime/approval-conversation-turn.ts +4 -0
  80. package/src/runtime/auth/__tests__/context.test.ts +130 -0
  81. package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
  82. package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
  83. package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
  84. package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
  85. package/src/runtime/auth/__tests__/policy.test.ts +29 -0
  86. package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
  87. package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
  88. package/src/runtime/auth/__tests__/subject.test.ts +149 -0
  89. package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
  90. package/src/runtime/auth/context.ts +62 -0
  91. package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
  92. package/src/runtime/auth/external-assistant-id.ts +69 -0
  93. package/src/runtime/auth/index.ts +37 -0
  94. package/src/runtime/auth/middleware.ts +127 -0
  95. package/src/runtime/auth/policy.ts +17 -0
  96. package/src/runtime/auth/route-policy.ts +261 -0
  97. package/src/runtime/auth/scopes.ts +64 -0
  98. package/src/runtime/auth/subject.ts +68 -0
  99. package/src/runtime/auth/token-service.ts +275 -0
  100. package/src/runtime/auth/types.ts +79 -0
  101. package/src/runtime/channel-approval-parser.ts +11 -5
  102. package/src/runtime/channel-approval-types.ts +1 -1
  103. package/src/runtime/channel-approvals.ts +22 -1
  104. package/src/runtime/guardian-action-followup-executor.ts +2 -2
  105. package/src/runtime/guardian-context-resolver.ts +15 -0
  106. package/src/runtime/guardian-decision-types.ts +23 -6
  107. package/src/runtime/guardian-outbound-actions.ts +4 -22
  108. package/src/runtime/guardian-reply-router.ts +5 -3
  109. package/src/runtime/http-server.ts +210 -182
  110. package/src/runtime/http-types.ts +11 -1
  111. package/src/runtime/local-actor-identity.ts +25 -0
  112. package/src/runtime/pending-interactions.ts +1 -0
  113. package/src/runtime/routes/approval-routes.ts +42 -59
  114. package/src/runtime/routes/channel-route-shared.ts +9 -41
  115. package/src/runtime/routes/channel-routes.ts +0 -2
  116. package/src/runtime/routes/conversation-routes.ts +39 -49
  117. package/src/runtime/routes/events-routes.ts +15 -22
  118. package/src/runtime/routes/guardian-action-routes.ts +46 -51
  119. package/src/runtime/routes/guardian-approval-interception.ts +6 -5
  120. package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
  121. package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
  122. package/src/runtime/routes/inbound-message-handler.ts +39 -45
  123. package/src/runtime/routes/pairing-routes.ts +9 -9
  124. package/src/runtime/routes/secret-routes.ts +90 -45
  125. package/src/runtime/routes/surface-action-routes.ts +12 -2
  126. package/src/runtime/routes/trust-rules-routes.ts +13 -0
  127. package/src/runtime/routes/twilio-routes.ts +3 -3
  128. package/src/runtime/session-approval-overrides.ts +86 -0
  129. package/src/security/keychain-to-encrypted-migration.ts +8 -1
  130. package/src/skills/frontmatter.ts +44 -1
  131. package/src/tools/permission-checker.ts +226 -74
  132. package/src/runtime/actor-token-service.ts +0 -234
  133. package/src/runtime/middleware/actor-token.ts +0 -265
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
 
3
3
  import { parseApprovalDecision } from '../runtime/channel-approval-parser.js';
4
+ import { parseCallbackData } from '../runtime/routes/channel-route-shared.js';
4
5
 
5
6
  // ═══════════════════════════════════════════════════════════════════════════
6
7
  // Plain-text approval decision parser
@@ -32,6 +33,48 @@ describe('parseApprovalDecision', () => {
32
33
  expect(result!.source).toBe('plain_text');
33
34
  });
34
35
 
36
+ // ── Approve for 10 minutes ────────────────────────────────────────
37
+
38
+ test.each([
39
+ 'approve for 10 minutes',
40
+ 'Approve for 10 minutes',
41
+ 'APPROVE FOR 10 MINUTES',
42
+ 'allow for 10 minutes',
43
+ 'Allow for 10 minutes',
44
+ 'ALLOW FOR 10 MINUTES',
45
+ 'approve 10m',
46
+ 'Approve 10m',
47
+ 'APPROVE 10M',
48
+ 'allow 10m',
49
+ 'approve 10 min',
50
+ 'allow 10 min',
51
+ ])('recognises "%s" as approve_10m', (input) => {
52
+ const result = parseApprovalDecision(input);
53
+ expect(result).not.toBeNull();
54
+ expect(result!.action).toBe('approve_10m');
55
+ expect(result!.source).toBe('plain_text');
56
+ });
57
+
58
+ // ── Approve for thread ────────────────────────────────────────────
59
+
60
+ test.each([
61
+ 'approve for thread',
62
+ 'Approve for thread',
63
+ 'APPROVE FOR THREAD',
64
+ 'allow for thread',
65
+ 'Allow for thread',
66
+ 'ALLOW FOR THREAD',
67
+ 'approve thread',
68
+ 'Approve thread',
69
+ 'APPROVE THREAD',
70
+ 'allow thread',
71
+ ])('recognises "%s" as approve_thread', (input) => {
72
+ const result = parseApprovalDecision(input);
73
+ expect(result).not.toBeNull();
74
+ expect(result!.action).toBe('approve_thread');
75
+ expect(result!.source).toBe('plain_text');
76
+ });
77
+
35
78
  // ── Approve always ────────────────────────────────────────────────
36
79
 
37
80
  test.each([
@@ -130,6 +173,20 @@ describe('parseApprovalDecision', () => {
130
173
  expect(result!.requestId).toBe('req-789');
131
174
  });
132
175
 
176
+ test('extracts requestId from [ref:...] tag with approve_10m decision', () => {
177
+ const result = parseApprovalDecision('approve for 10 minutes [ref:req-timer]');
178
+ expect(result).not.toBeNull();
179
+ expect(result!.action).toBe('approve_10m');
180
+ expect(result!.requestId).toBe('req-timer');
181
+ });
182
+
183
+ test('extracts requestId from [ref:...] tag with approve_thread decision', () => {
184
+ const result = parseApprovalDecision('approve for thread [ref:req-thread]');
185
+ expect(result).not.toBeNull();
186
+ expect(result!.action).toBe('approve_thread');
187
+ expect(result!.requestId).toBe('req-thread');
188
+ });
189
+
133
190
  test('handles ref tag on separate line', () => {
134
191
  const result = parseApprovalDecision('yes\n[ref:req-abc-123]');
135
192
  expect(result).not.toBeNull();
@@ -143,3 +200,46 @@ describe('parseApprovalDecision', () => {
143
200
  expect(result!.requestId).toBeUndefined();
144
201
  });
145
202
  });
203
+
204
+ // ═══════════════════════════════════════════════════════════════════════════
205
+ // Callback data parser
206
+ // ═══════════════════════════════════════════════════════════════════════════
207
+
208
+ describe('parseCallbackData', () => {
209
+ test.each([
210
+ ['apr:req-123:approve_once', 'approve_once'],
211
+ ['apr:req-123:approve_10m', 'approve_10m'],
212
+ ['apr:req-123:approve_thread', 'approve_thread'],
213
+ ['apr:req-123:approve_always', 'approve_always'],
214
+ ['apr:req-123:reject', 'reject'],
215
+ ] as const)('parses "%s" as action "%s"', (data, expectedAction) => {
216
+ const result = parseCallbackData(data);
217
+ expect(result).not.toBeNull();
218
+ expect(result!.action).toBe(expectedAction);
219
+ expect(result!.requestId).toBe('req-123');
220
+ expect(result!.source).toBe('telegram_button');
221
+ });
222
+
223
+ test('parses whatsapp source channel', () => {
224
+ const result = parseCallbackData('apr:req-456:approve_10m', 'whatsapp');
225
+ expect(result).not.toBeNull();
226
+ expect(result!.action).toBe('approve_10m');
227
+ expect(result!.source).toBe('whatsapp_button');
228
+ });
229
+
230
+ test('returns null for unknown action', () => {
231
+ expect(parseCallbackData('apr:req-123:unknown_action')).toBeNull();
232
+ });
233
+
234
+ test('returns null for missing prefix', () => {
235
+ expect(parseCallbackData('xyz:req-123:approve_once')).toBeNull();
236
+ });
237
+
238
+ test('returns null for incomplete data', () => {
239
+ expect(parseCallbackData('apr:req-123')).toBeNull();
240
+ });
241
+
242
+ test('returns null for empty requestId', () => {
243
+ expect(parseCallbackData('apr::approve_once')).toBeNull();
244
+ });
245
+ });
@@ -58,11 +58,18 @@ mock.module('../runtime/local-actor-identity.js', () => ({
58
58
  resolveLocalIpcGuardianContext: () => ({ trustClass: 'guardian', sourceChannel: 'vellum' }),
59
59
  }));
60
60
 
61
+ import type { AuthContext } from '../runtime/auth/types.js';
61
62
  import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
62
63
 
63
- const testServer = {
64
- requestIP: () => ({ address: '127.0.0.1' }),
65
- } as unknown as import('../runtime/middleware/actor-token.js').ServerWithRequestIP;
64
+ const testAuthContext: AuthContext = {
65
+ subject: 'actor:self:test-guardian',
66
+ principalType: 'actor',
67
+ assistantId: 'self',
68
+ actorPrincipalId: 'test-guardian',
69
+ scopeProfile: 'actor_client_v1',
70
+ scopes: new Set(['chat.read', 'chat.write', 'approval.read', 'approval.write', 'settings.read', 'settings.write', 'attachments.read', 'attachments.write', 'calls.read', 'calls.write', 'feature_flags.read', 'feature_flags.write']),
71
+ policyEpoch: 1,
72
+ };
66
73
 
67
74
  describe('handleSendMessage canonical guardian reply interception', () => {
68
75
  beforeEach(() => {
@@ -121,7 +128,7 @@ describe('handleSendMessage canonical guardian reply interception', () => {
121
128
  assistantEventHub: { publish: async () => {} } as any,
122
129
  resolveAttachments: () => [],
123
130
  },
124
- }, testServer);
131
+ }, testAuthContext);
125
132
 
126
133
  expect(res.status).toBe(202);
127
134
  const body = await res.json() as { accepted: boolean; messageId?: string };
@@ -184,7 +191,7 @@ describe('handleSendMessage canonical guardian reply interception', () => {
184
191
  assistantEventHub: { publish: async () => {} } as any,
185
192
  resolveAttachments: () => [],
186
193
  },
187
- }, testServer);
194
+ }, testAuthContext);
188
195
 
189
196
  expect(res.status).toBe(202);
190
197
  expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
@@ -245,7 +252,7 @@ describe('handleSendMessage canonical guardian reply interception', () => {
245
252
  assistantEventHub: { publish: async () => {} } as any,
246
253
  resolveAttachments: () => [],
247
254
  },
248
- }, testServer);
255
+ }, testAuthContext);
249
256
 
250
257
  expect(res.status).toBe(202);
251
258
  expect(routeGuardianReplyMock).toHaveBeenCalledTimes(1);
@@ -21,11 +21,18 @@ mock.module('../runtime/local-actor-identity.js', () => ({
21
21
  resolveLocalIpcGuardianContext: (sourceChannel: string) => ({ trustClass: 'guardian', sourceChannel }),
22
22
  }));
23
23
 
24
- import type { ServerWithRequestIP } from '../runtime/middleware/actor-token.js';
24
+ import type { AuthContext } from '../runtime/auth/types.js';
25
25
  import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
26
26
 
27
- const mockLoopbackServer: ServerWithRequestIP = {
28
- requestIP: () => ({ address: '127.0.0.1', family: 'IPv4', port: 0 }),
27
+ /** Synthetic AuthContext for tests — mimics a local actor with full scopes. */
28
+ const mockAuthContext: AuthContext = {
29
+ subject: 'actor:self:test-principal',
30
+ principalType: 'actor',
31
+ assistantId: 'self',
32
+ actorPrincipalId: 'test-principal',
33
+ scopeProfile: 'actor_client_v1',
34
+ scopes: new Set(['chat.read', 'chat.write', 'approval.read', 'approval.write']),
35
+ policyEpoch: 1,
29
36
  };
30
37
 
31
38
  describe('handleSendMessage', () => {
@@ -50,7 +57,7 @@ describe('handleSendMessage', () => {
50
57
  capturedSourceChannel = sourceChannel;
51
58
  return { messageId: 'msg-legacy-fallback' };
52
59
  },
53
- }, mockLoopbackServer);
60
+ }, mockAuthContext);
54
61
 
55
62
  const body = await res.json() as { accepted: boolean; messageId: string };
56
63
  expect(res.status).toBe(202);
@@ -58,15 +58,22 @@ import {
58
58
  import { initializeDb, resetDb } from '../memory/db.js';
59
59
  import { getDb } from '../memory/db.js';
60
60
  import { conversations } from '../memory/schema.js';
61
- import type { ServerWithRequestIP } from '../runtime/middleware/actor-token.js';
61
+ import type { AuthContext } from '../runtime/auth/types.js';
62
62
  import {
63
63
  handleGuardianActionDecision,
64
64
  handleGuardianActionsPending,
65
65
  listGuardianDecisionPrompts,
66
66
  } from '../runtime/routes/guardian-action-routes.js';
67
67
 
68
- const mockLoopbackServer: ServerWithRequestIP = {
69
- requestIP: () => ({ address: '127.0.0.1', family: 'IPv4', port: 0 }),
68
+ /** Synthetic AuthContext for tests -- mimics a local actor with full scopes. */
69
+ const mockAuthContext: AuthContext = {
70
+ subject: 'actor:self:test-principal',
71
+ principalType: 'actor',
72
+ assistantId: 'self',
73
+ actorPrincipalId: 'test-principal',
74
+ scopeProfile: 'actor_client_v1',
75
+ scopes: new Set(['chat.read', 'chat.write', 'approval.read', 'approval.write']),
76
+ policyEpoch: 1,
70
77
  };
71
78
 
72
79
  initializeDb();
@@ -152,7 +159,7 @@ describe('HTTP handleGuardianActionDecision', () => {
152
159
  method: 'POST',
153
160
  body: JSON.stringify({ action: 'approve_once' }),
154
161
  });
155
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
162
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
156
163
  expect(res.status).toBe(400);
157
164
  const body = await res.json();
158
165
  expect(body.error.message).toContain('requestId');
@@ -163,7 +170,7 @@ describe('HTTP handleGuardianActionDecision', () => {
163
170
  method: 'POST',
164
171
  body: JSON.stringify({ requestId: 'req-1' }),
165
172
  });
166
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
173
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
167
174
  expect(res.status).toBe(400);
168
175
  const body = await res.json();
169
176
  expect(body.error.message).toContain('action');
@@ -174,7 +181,7 @@ describe('HTTP handleGuardianActionDecision', () => {
174
181
  method: 'POST',
175
182
  body: JSON.stringify({ requestId: 'req-1', action: 'nuke_from_orbit' }),
176
183
  });
177
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
184
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
178
185
  expect(res.status).toBe(400);
179
186
  const body = await res.json();
180
187
  expect(body.error.message).toContain('Invalid action');
@@ -187,7 +194,7 @@ describe('HTTP handleGuardianActionDecision', () => {
187
194
  method: 'POST',
188
195
  body: JSON.stringify({ requestId: 'nonexistent', action: 'approve_once' }),
189
196
  });
190
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
197
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
191
198
  expect(res.status).toBe(404);
192
199
  });
193
200
 
@@ -199,7 +206,7 @@ describe('HTTP handleGuardianActionDecision', () => {
199
206
  method: 'POST',
200
207
  body: JSON.stringify({ requestId: 'req-gd-1', action: 'approve_once' }),
201
208
  });
202
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
209
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
203
210
  expect(res.status).toBe(200);
204
211
  const body = await res.json();
205
212
  expect(body.applied).toBe(true);
@@ -214,7 +221,7 @@ describe('HTTP handleGuardianActionDecision', () => {
214
221
  method: 'POST',
215
222
  body: JSON.stringify({ requestId: 'req-scope-1', action: 'approve_once', conversationId: 'conv-wrong' }),
216
223
  });
217
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
224
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
218
225
  expect(res.status).toBe(404);
219
226
  const body = await res.json();
220
227
  expect(body.error.message).toContain('No pending guardian action');
@@ -229,7 +236,7 @@ describe('HTTP handleGuardianActionDecision', () => {
229
236
  method: 'POST',
230
237
  body: JSON.stringify({ requestId: 'req-scope-2', action: 'reject', conversationId: 'conv-match' }),
231
238
  });
232
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
239
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
233
240
  expect(res.status).toBe(200);
234
241
  const body = await res.json();
235
242
  expect(body.applied).toBe(true);
@@ -243,7 +250,7 @@ describe('HTTP handleGuardianActionDecision', () => {
243
250
  method: 'POST',
244
251
  body: JSON.stringify({ requestId: 'req-scope-3', action: 'approve_once' }),
245
252
  });
246
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
253
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
247
254
  expect(res.status).toBe(200);
248
255
  });
249
256
 
@@ -261,7 +268,7 @@ describe('HTTP handleGuardianActionDecision', () => {
261
268
  method: 'POST',
262
269
  body: JSON.stringify({ requestId: 'req-access-1', action: 'approve_once' }),
263
270
  });
264
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
271
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
265
272
  expect(res.status).toBe(200);
266
273
  const body = await res.json();
267
274
  expect(body.applied).toBe(true);
@@ -283,7 +290,7 @@ describe('HTTP handleGuardianActionDecision', () => {
283
290
  method: 'POST',
284
291
  body: JSON.stringify({ requestId: 'req-voice-access-1', action: 'approve_once' }),
285
292
  });
286
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
293
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
287
294
  expect(res.status).toBe(200);
288
295
  const body = await res.json();
289
296
  expect(body.applied).toBe(true);
@@ -298,7 +305,7 @@ describe('HTTP handleGuardianActionDecision', () => {
298
305
  method: 'POST',
299
306
  body: JSON.stringify({ requestId: 'req-stale-1', action: 'approve_once' }),
300
307
  });
301
- const res = await handleGuardianActionDecision(req, mockLoopbackServer);
308
+ const res = await handleGuardianActionDecision(req, mockAuthContext);
302
309
  const body = await res.json();
303
310
  expect(body.applied).toBe(false);
304
311
  expect(body.reason).toBe('already_resolved');
@@ -314,7 +321,7 @@ describe('HTTP handleGuardianActionDecision', () => {
314
321
  method: 'POST',
315
322
  body: JSON.stringify({ requestId: 'req-actor-1', action: 'approve_once' }),
316
323
  });
317
- await handleGuardianActionDecision(req, mockLoopbackServer);
324
+ await handleGuardianActionDecision(req, mockAuthContext);
318
325
  const call = mockApplyCanonicalGuardianDecision.mock.calls[0]![0] as Record<string, unknown>;
319
326
  const actorContext = call.actorContext as Record<string, unknown>;
320
327
  expect(actorContext.channel).toBe('vellum');
@@ -330,8 +337,8 @@ describe('HTTP handleGuardianActionsPending', () => {
330
337
  beforeEach(resetTables);
331
338
 
332
339
  test('returns 400 when conversationId is missing', () => {
333
- const req = new Request('http://localhost/v1/guardian-actions/pending');
334
- const res = handleGuardianActionsPending(req, mockLoopbackServer);
340
+ const url = new URL('http://localhost/v1/guardian-actions/pending');
341
+ const res = handleGuardianActionsPending(url, mockAuthContext);
335
342
  expect(res.status).toBe(400);
336
343
  });
337
344
 
@@ -342,8 +349,8 @@ describe('HTTP handleGuardianActionsPending', () => {
342
349
  questionText: 'Run bash: ls',
343
350
  });
344
351
 
345
- const req = new Request('http://localhost/v1/guardian-actions/pending?conversationId=conv-list');
346
- const res = handleGuardianActionsPending(req, mockLoopbackServer);
352
+ const url = new URL('http://localhost/v1/guardian-actions/pending?conversationId=conv-list');
353
+ const res = handleGuardianActionsPending(url, mockAuthContext);
347
354
  expect(res.status).toBe(200);
348
355
 
349
356
  // Verify the prompts directly via the shared helper
@@ -0,0 +1,65 @@
1
+ import { beforeEach, describe, expect, jest, mock, test } from 'bun:test';
2
+
3
+ const mockConnect = jest.fn();
4
+ const mockDisconnect = jest.fn();
5
+ let mockIsConnected = true;
6
+
7
+ mock.module('../mcp/client.js', () => ({
8
+ McpClient: class {
9
+ get isConnected() { return mockIsConnected; }
10
+ connect = mockConnect;
11
+ disconnect = mockDisconnect;
12
+ },
13
+ }));
14
+
15
+ const { checkServerHealth } = await import('../cli/mcp.js');
16
+
17
+ const serverConfig = (overrides = {}) => ({
18
+ transport: { type: 'streamable-http' as const, url: 'https://example.com/mcp' },
19
+ enabled: true,
20
+ defaultRiskLevel: 'high' as const,
21
+ maxTools: 20,
22
+ ...overrides,
23
+ });
24
+
25
+ describe('checkServerHealth', () => {
26
+ beforeEach(() => {
27
+ mockConnect.mockReset();
28
+ mockDisconnect.mockReset();
29
+ mockIsConnected = true;
30
+ });
31
+
32
+ test('returns Connected when server connects successfully', async () => {
33
+ mockConnect.mockResolvedValue(undefined);
34
+ mockDisconnect.mockResolvedValue(undefined);
35
+
36
+ const result = await checkServerHealth('test', serverConfig());
37
+ expect(result).toContain('Connected');
38
+ expect(mockDisconnect).toHaveBeenCalled();
39
+ });
40
+
41
+ test('returns Needs authentication when isConnected is false', async () => {
42
+ mockConnect.mockResolvedValue(undefined);
43
+ mockIsConnected = false;
44
+
45
+ const result = await checkServerHealth('test', serverConfig());
46
+ expect(result).toContain('Needs authentication');
47
+ });
48
+
49
+ test('returns Error when connect throws', async () => {
50
+ mockConnect.mockRejectedValue(new Error('Connection refused'));
51
+ mockDisconnect.mockResolvedValue(undefined);
52
+
53
+ const result = await checkServerHealth('test', serverConfig());
54
+ expect(result).toContain('Error');
55
+ expect(result).toContain('Connection refused');
56
+ });
57
+
58
+ test('returns Timed out when connect hangs', async () => {
59
+ mockConnect.mockImplementation(() => new Promise(() => {}));
60
+ mockDisconnect.mockResolvedValue(undefined);
61
+
62
+ const result = await checkServerHealth('test', serverConfig(), 100);
63
+ expect(result).toContain('Timed out');
64
+ });
65
+ });
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import type { UserDecision } from "../permissions/types.js";
4
+ import { isAllowDecision } from "../permissions/types.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Tests
8
+ // ---------------------------------------------------------------------------
9
+
10
+ describe("isAllowDecision", () => {
11
+ const allowDecisions: UserDecision[] = [
12
+ "allow",
13
+ "allow_10m",
14
+ "allow_thread",
15
+ "always_allow",
16
+ "always_allow_high_risk",
17
+ "temporary_override",
18
+ ];
19
+
20
+ for (const decision of allowDecisions) {
21
+ test(`returns true for '${decision}'`, () => {
22
+ expect(isAllowDecision(decision)).toBe(true);
23
+ });
24
+ }
25
+
26
+ test("returns false for 'deny'", () => {
27
+ expect(isAllowDecision("deny")).toBe(false);
28
+ });
29
+
30
+ test("returns false for 'always_deny'", () => {
31
+ expect(isAllowDecision("always_deny")).toBe(false);
32
+ });
33
+ });
@@ -0,0 +1,121 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+
3
+ import {
4
+ _internals,
5
+ clearScanStore,
6
+ getSenderMessageIds,
7
+ getSenderMetadata,
8
+ storeScanResult,
9
+ } from "../config/bundled-skills/messaging/tools/scan-result-store.js";
10
+
11
+ describe("scan-result-store", () => {
12
+ beforeEach(() => {
13
+ clearScanStore();
14
+ });
15
+
16
+ const makeSenders = (count: number) =>
17
+ Array.from({ length: count }, (_, i) => ({
18
+ id: `sender-${i}`,
19
+ messageIds: [`msg-${i}-a`, `msg-${i}-b`],
20
+ newestMessageId: `msg-${i}-a`,
21
+ newestUnsubscribableMessageId: i % 2 === 0 ? `msg-${i}-a` : null,
22
+ }));
23
+
24
+ it("stores and retrieves message IDs", () => {
25
+ const senders = makeSenders(3);
26
+ const scanId = storeScanResult(senders);
27
+
28
+ const ids = getSenderMessageIds(scanId, ["sender-0", "sender-2"]);
29
+ expect(ids).toEqual(["msg-0-a", "msg-0-b", "msg-2-a", "msg-2-b"]);
30
+ });
31
+
32
+ it("returns null for unknown scan ID", () => {
33
+ expect(getSenderMessageIds("nonexistent", ["sender-0"])).toBeNull();
34
+ });
35
+
36
+ it("returns empty array for unknown sender IDs", () => {
37
+ const scanId = storeScanResult(makeSenders(1));
38
+ const ids = getSenderMessageIds(scanId, ["unknown-sender"]);
39
+ expect(ids).toEqual([]);
40
+ });
41
+
42
+ it("retrieves sender metadata", () => {
43
+ const senders = makeSenders(2);
44
+ const scanId = storeScanResult(senders);
45
+
46
+ const meta0 = getSenderMetadata(scanId, "sender-0");
47
+ expect(meta0).toEqual({
48
+ newestMessageId: "msg-0-a",
49
+ newestUnsubscribableMessageId: "msg-0-a",
50
+ });
51
+
52
+ const meta1 = getSenderMetadata(scanId, "sender-1");
53
+ expect(meta1).toEqual({
54
+ newestMessageId: "msg-1-a",
55
+ newestUnsubscribableMessageId: null,
56
+ });
57
+ });
58
+
59
+ it("returns null metadata for unknown sender", () => {
60
+ const scanId = storeScanResult(makeSenders(1));
61
+ expect(getSenderMetadata(scanId, "unknown")).toBeNull();
62
+ });
63
+
64
+ it("evicts oldest entry when at capacity (LRU)", () => {
65
+ const scanIds: string[] = [];
66
+ for (let i = 0; i < 16; i++) {
67
+ scanIds.push(
68
+ storeScanResult([
69
+ {
70
+ id: `s-${i}`,
71
+ messageIds: [`m-${i}`],
72
+ newestMessageId: `m-${i}`,
73
+ newestUnsubscribableMessageId: null,
74
+ },
75
+ ]),
76
+ );
77
+ }
78
+
79
+ // All 16 should be present
80
+ expect(getSenderMessageIds(scanIds[0], ["s-0"])).toEqual(["m-0"]);
81
+
82
+ // Adding a 17th should evict the oldest (scanIds[0] was accessed last via getSenderMessageIds, so scanIds[1] is oldest)
83
+ const newId = storeScanResult([
84
+ {
85
+ id: "s-new",
86
+ messageIds: ["m-new"],
87
+ newestMessageId: "m-new",
88
+ newestUnsubscribableMessageId: null,
89
+ },
90
+ ]);
91
+
92
+ // scanIds[1] should be evicted (it was the LRU after we accessed scanIds[0])
93
+ expect(getSenderMessageIds(scanIds[1], ["s-1"])).toBeNull();
94
+ // The new entry and scanIds[0] should still be present
95
+ expect(getSenderMessageIds(newId, ["s-new"])).toEqual(["m-new"]);
96
+ expect(getSenderMessageIds(scanIds[0], ["s-0"])).toEqual(["m-0"]);
97
+ });
98
+
99
+ it("expires entries after TTL", () => {
100
+ const scanId = storeScanResult(makeSenders(1));
101
+
102
+ // Manually age the entry beyond TTL
103
+ const entry = _internals.store.get(scanId);
104
+ expect(entry).toBeDefined();
105
+ entry!.createdAt = Date.now() - _internals.TTL_MS - 1;
106
+
107
+ expect(getSenderMessageIds(scanId, ["sender-0"])).toBeNull();
108
+ // Entry should be cleaned up
109
+ expect(_internals.store.has(scanId)).toBe(false);
110
+ });
111
+
112
+ it("expires entries in getSenderMetadata after TTL", () => {
113
+ const scanId = storeScanResult(makeSenders(1));
114
+
115
+ const entry = _internals.store.get(scanId);
116
+ entry!.createdAt = Date.now() - _internals.TTL_MS - 1;
117
+
118
+ expect(getSenderMetadata(scanId, "sender-0")).toBeNull();
119
+ expect(_internals.store.has(scanId)).toBe(false);
120
+ });
121
+ });