@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.
- package/ARCHITECTURE.md +77 -38
- package/README.md +10 -12
- package/package.json +1 -1
- package/src/__tests__/actor-token-service.test.ts +108 -522
- package/src/__tests__/channel-approval-routes.test.ts +92 -239
- package/src/__tests__/channel-approval.test.ts +100 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +13 -6
- package/src/__tests__/conversation-routes.test.ts +11 -4
- package/src/__tests__/guardian-actions-endpoint.test.ts +26 -19
- package/src/__tests__/mcp-health-check.test.ts +65 -0
- package/src/__tests__/permission-types.test.ts +33 -0
- package/src/__tests__/scan-result-store.test.ts +121 -0
- package/src/__tests__/session-agent-loop.test.ts +120 -0
- package/src/__tests__/session-approval-overrides.test.ts +205 -0
- package/src/__tests__/session-surfaces-task-progress.test.ts +38 -0
- package/src/amazon/client.ts +8 -5
- package/src/approvals/guardian-decision-primitive.ts +14 -9
- package/src/approvals/guardian-request-resolvers.ts +2 -2
- package/src/calls/call-controller.ts +2 -2
- package/src/calls/twilio-routes.ts +2 -2
- package/src/cli/mcp.ts +3 -3
- package/src/cli.ts +24 -0
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +19 -130
- package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +8 -6
- package/src/config/bundled-skills/google-calendar/SKILL.md +1 -1
- package/src/config/bundled-skills/messaging/SKILL.md +49 -14
- package/src/config/bundled-skills/messaging/TOOLS.json +52 -9
- package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +35 -11
- package/src/config/bundled-skills/messaging/tools/gmail-draft.ts +3 -1
- package/src/config/bundled-skills/messaging/tools/gmail-forward.ts +5 -6
- package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +10 -2
- package/src/config/bundled-skills/messaging/tools/gmail-send-draft.ts +20 -0
- package/src/config/bundled-skills/messaging/tools/gmail-send-with-attachments.ts +3 -4
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +16 -8
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +76 -0
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +10 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +11 -3
- package/src/config/bundled-skills/messaging/tools/scan-result-store.ts +86 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
- package/src/config/bundled-skills/skills-catalog/SKILL.md +31 -8
- package/src/config/bundled-skills/slack/tools/slack-add-reaction.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-delete-message.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-leave-channel.ts +1 -1
- package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +79 -24
- package/src/config/bundled-skills/sms-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/telegram-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/approval-generators.ts +6 -3
- package/src/daemon/handlers/config-ingress.ts +2 -6
- package/src/daemon/handlers/guardian-actions.ts +1 -1
- package/src/daemon/handlers/sessions.ts +4 -1
- package/src/daemon/handlers/shared.ts +3 -0
- package/src/daemon/handlers/skills.ts +32 -0
- package/src/daemon/ipc-contract/messages.ts +3 -1
- package/src/daemon/ipc-handler.ts +24 -0
- package/src/daemon/ipc-validate.ts +1 -1
- package/src/daemon/lifecycle.ts +6 -8
- package/src/daemon/server.ts +8 -3
- package/src/daemon/session-agent-loop.ts +19 -1
- package/src/daemon/session-attachments.ts +2 -1
- package/src/daemon/session-history.ts +2 -2
- package/src/daemon/session-process.ts +5 -9
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session-tool-setup.ts +216 -69
- package/src/daemon/session.ts +24 -1
- package/src/events/domain-events.ts +1 -1
- package/src/events/tool-domain-event-publisher.ts +5 -10
- package/src/influencer/client.ts +8 -7
- package/src/messaging/providers/gmail/client.ts +33 -1
- package/src/messaging/providers/gmail/mime-builder.ts +5 -1
- package/src/messaging/providers/sms/adapter.ts +3 -7
- package/src/messaging/providers/telegram-bot/adapter.ts +3 -7
- package/src/messaging/providers/whatsapp/adapter.ts +3 -7
- package/src/notifications/adapters/sms.ts +2 -2
- package/src/notifications/adapters/telegram.ts +2 -2
- package/src/permissions/prompter.ts +2 -0
- package/src/permissions/types.ts +11 -1
- package/src/runtime/approval-conversation-turn.ts +4 -0
- package/src/runtime/auth/__tests__/context.test.ts +130 -0
- package/src/runtime/auth/__tests__/credential-service.test.ts +277 -0
- package/src/runtime/auth/__tests__/guard-tests.test.ts +289 -0
- package/src/runtime/auth/__tests__/ipc-auth-context.test.ts +71 -0
- package/src/runtime/auth/__tests__/middleware.test.ts +239 -0
- package/src/runtime/auth/__tests__/policy.test.ts +29 -0
- package/src/runtime/auth/__tests__/route-policy.test.ts +166 -0
- package/src/runtime/auth/__tests__/scopes.test.ts +109 -0
- package/src/runtime/auth/__tests__/subject.test.ts +149 -0
- package/src/runtime/auth/__tests__/token-service.test.ts +263 -0
- package/src/runtime/auth/context.ts +62 -0
- package/src/runtime/{actor-refresh-token-service.ts → auth/credential-service.ts} +112 -79
- package/src/runtime/auth/external-assistant-id.ts +69 -0
- package/src/runtime/auth/index.ts +37 -0
- package/src/runtime/auth/middleware.ts +127 -0
- package/src/runtime/auth/policy.ts +17 -0
- package/src/runtime/auth/route-policy.ts +261 -0
- package/src/runtime/auth/scopes.ts +64 -0
- package/src/runtime/auth/subject.ts +68 -0
- package/src/runtime/auth/token-service.ts +275 -0
- package/src/runtime/auth/types.ts +79 -0
- package/src/runtime/channel-approval-parser.ts +11 -5
- package/src/runtime/channel-approval-types.ts +1 -1
- package/src/runtime/channel-approvals.ts +22 -1
- package/src/runtime/guardian-action-followup-executor.ts +2 -2
- package/src/runtime/guardian-context-resolver.ts +15 -0
- package/src/runtime/guardian-decision-types.ts +23 -6
- package/src/runtime/guardian-outbound-actions.ts +4 -22
- package/src/runtime/guardian-reply-router.ts +5 -3
- package/src/runtime/http-server.ts +210 -182
- package/src/runtime/http-types.ts +11 -1
- package/src/runtime/local-actor-identity.ts +25 -0
- package/src/runtime/pending-interactions.ts +1 -0
- package/src/runtime/routes/approval-routes.ts +42 -59
- package/src/runtime/routes/channel-route-shared.ts +9 -41
- package/src/runtime/routes/channel-routes.ts +0 -2
- package/src/runtime/routes/conversation-routes.ts +39 -49
- package/src/runtime/routes/events-routes.ts +15 -22
- package/src/runtime/routes/guardian-action-routes.ts +46 -51
- package/src/runtime/routes/guardian-approval-interception.ts +6 -5
- package/src/runtime/routes/guardian-bootstrap-routes.ts +12 -8
- package/src/runtime/routes/guardian-refresh-routes.ts +2 -2
- package/src/runtime/routes/inbound-message-handler.ts +39 -45
- package/src/runtime/routes/pairing-routes.ts +9 -9
- package/src/runtime/routes/secret-routes.ts +90 -45
- package/src/runtime/routes/surface-action-routes.ts +12 -2
- package/src/runtime/routes/trust-rules-routes.ts +13 -0
- package/src/runtime/routes/twilio-routes.ts +3 -3
- package/src/runtime/session-approval-overrides.ts +86 -0
- package/src/security/keychain-to-encrypted-migration.ts +8 -1
- package/src/skills/frontmatter.ts +44 -1
- package/src/tools/permission-checker.ts +226 -74
- package/src/runtime/actor-token-service.ts +0 -234
- 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
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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 {
|
|
24
|
+
import type { AuthContext } from '../runtime/auth/types.js';
|
|
25
25
|
import { handleSendMessage } from '../runtime/routes/conversation-routes.js';
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
},
|
|
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 {
|
|
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
|
-
|
|
69
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
|
334
|
-
const res = handleGuardianActionsPending(
|
|
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
|
|
346
|
-
const res = handleGuardianActionsPending(
|
|
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
|
+
});
|