@vellumai/assistant 0.3.28 → 0.4.0
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 +33 -3
- package/bun.lock +4 -1
- package/docs/trusted-contact-access.md +9 -2
- package/package.json +6 -3
- package/scripts/ipc/generate-swift.ts +3 -3
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +80 -0
- package/src/__tests__/agent-loop-thinking.test.ts +1 -1
- package/src/__tests__/approval-routes-http.test.ts +13 -5
- package/src/__tests__/asset-materialize-tool.test.ts +2 -0
- package/src/__tests__/asset-search-tool.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +4 -2
- package/src/__tests__/attachments-store.test.ts +2 -0
- package/src/__tests__/browser-skill-endstate.test.ts +3 -3
- package/src/__tests__/call-controller.test.ts +30 -29
- package/src/__tests__/call-routes-http.test.ts +34 -32
- package/src/__tests__/call-start-guardian-guard.test.ts +2 -0
- package/src/__tests__/channel-invite-transport.test.ts +6 -6
- package/src/__tests__/channel-reply-delivery.test.ts +19 -0
- package/src/__tests__/channel-retry-sweep.test.ts +130 -0
- package/src/__tests__/clarification-resolver.test.ts +2 -0
- package/src/__tests__/claude-code-skill-regression.test.ts +2 -0
- package/src/__tests__/claude-code-tool-profiles.test.ts +2 -0
- package/src/__tests__/commit-message-enrichment-service.test.ts +9 -1
- package/src/__tests__/computer-use-session-lifecycle.test.ts +2 -0
- package/src/__tests__/computer-use-session-working-dir.test.ts +1 -0
- package/src/__tests__/computer-use-skill-lifecycle-cleanup.test.ts +2 -0
- package/src/__tests__/config-schema.test.ts +5 -5
- package/src/__tests__/config-watcher.test.ts +3 -1
- package/src/__tests__/connection-policy.test.ts +14 -5
- package/src/__tests__/contacts-tools.test.ts +3 -1
- package/src/__tests__/contradiction-checker.test.ts +2 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-routes.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +16 -6
- package/src/__tests__/credential-vault-unit.test.ts +2 -2
- package/src/__tests__/credential-vault.test.ts +5 -4
- package/src/__tests__/daemon-lifecycle.test.ts +9 -0
- package/src/__tests__/daemon-server-session-init.test.ts +27 -0
- package/src/__tests__/elevenlabs-config.test.ts +2 -0
- package/src/__tests__/encrypted-store.test.ts +10 -5
- package/src/__tests__/followup-tools.test.ts +3 -1
- package/src/__tests__/gateway-only-enforcement.test.ts +21 -21
- package/src/__tests__/gmail-integration.test.ts +0 -1
- package/src/__tests__/guardian-control-plane-policy.test.ts +19 -19
- package/src/__tests__/guardian-dispatch.test.ts +2 -0
- package/src/__tests__/guardian-grant-minting.test.ts +68 -1
- package/src/__tests__/guardian-outbound-http.test.ts +12 -9
- package/src/__tests__/guardian-routing-invariants.test.ts +138 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +1 -0
- package/src/__tests__/handlers-slack-config.test.ts +3 -1
- package/src/__tests__/handlers-telegram-config.test.ts +3 -1
- package/src/__tests__/handlers-twilio-config.test.ts +3 -1
- package/src/__tests__/handlers-twitter-config.test.ts +3 -1
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +318 -0
- package/src/__tests__/heartbeat-service.test.ts +20 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +33 -0
- package/src/__tests__/ingress-reconcile.test.ts +3 -1
- package/src/__tests__/ingress-routes-http.test.ts +231 -4
- package/src/__tests__/intent-routing.test.ts +2 -0
- package/src/__tests__/ipc-snapshot.test.ts +13 -0
- package/src/__tests__/media-generate-image.test.ts +21 -0
- package/src/__tests__/media-reuse-story.e2e.test.ts +2 -0
- package/src/__tests__/memory-regressions.test.ts +20 -20
- package/src/__tests__/non-member-access-request.test.ts +183 -9
- package/src/__tests__/notification-decision-fallback.test.ts +2 -0
- package/src/__tests__/notification-decision-strategy.test.ts +61 -0
- package/src/__tests__/notification-guardian-path.test.ts +2 -0
- package/src/__tests__/oauth-connect-handler.test.ts +3 -1
- package/src/__tests__/oauth2-gateway-transport.test.ts +2 -0
- package/src/__tests__/onboarding-starter-tasks.test.ts +4 -4
- package/src/__tests__/pairing-routes.test.ts +171 -0
- package/src/__tests__/playbook-execution.test.ts +3 -1
- package/src/__tests__/playbook-tools.test.ts +3 -1
- package/src/__tests__/provider-error-scenarios.test.ts +59 -8
- package/src/__tests__/proxy-approval-callback.test.ts +2 -0
- package/src/__tests__/recording-handler.test.ts +11 -0
- package/src/__tests__/recording-intent-handler.test.ts +15 -0
- package/src/__tests__/recording-state-machine.test.ts +13 -2
- package/src/__tests__/registry.test.ts +7 -3
- package/src/__tests__/relay-server.test.ts +148 -28
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/runtime-events-sse-parity.test.ts +21 -0
- package/src/__tests__/runtime-events-sse.test.ts +4 -2
- package/src/__tests__/sandbox-diagnostics.test.ts +2 -0
- package/src/__tests__/schedule-tools.test.ts +3 -1
- package/src/__tests__/send-endpoint-busy.test.ts +4 -0
- package/src/__tests__/session-abort-tool-results.test.ts +23 -0
- package/src/__tests__/session-agent-loop.test.ts +16 -0
- package/src/__tests__/session-conflict-gate.test.ts +21 -0
- package/src/__tests__/session-load-history-repair.test.ts +27 -17
- package/src/__tests__/session-pre-run-repair.test.ts +23 -0
- package/src/__tests__/session-profile-injection.test.ts +21 -0
- package/src/__tests__/session-provider-retry-repair.test.ts +20 -0
- package/src/__tests__/session-queue.test.ts +23 -0
- package/src/__tests__/session-runtime-assembly.test.ts +50 -12
- package/src/__tests__/session-skill-tools.test.ts +27 -5
- package/src/__tests__/session-slash-known.test.ts +23 -0
- package/src/__tests__/session-slash-queue.test.ts +23 -0
- package/src/__tests__/session-slash-unknown.test.ts +23 -0
- package/src/__tests__/session-workspace-cache-state.test.ts +7 -0
- package/src/__tests__/session-workspace-injection.test.ts +21 -0
- package/src/__tests__/session-workspace-tool-tracking.test.ts +21 -0
- package/src/__tests__/shell-credential-ref.test.ts +2 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +6 -6
- package/src/__tests__/skill-load-feature-flag.test.ts +5 -4
- package/src/__tests__/skill-projection-feature-flag.test.ts +22 -0
- package/src/__tests__/skills.test.ts +8 -4
- package/src/__tests__/slack-channel-config.test.ts +3 -1
- package/src/__tests__/subagent-tools.test.ts +19 -0
- package/src/__tests__/swarm-recursion.test.ts +2 -0
- package/src/__tests__/swarm-session-integration.test.ts +2 -0
- package/src/__tests__/swarm-tool.test.ts +2 -0
- package/src/__tests__/system-prompt.test.ts +3 -1
- package/src/__tests__/task-compiler.test.ts +3 -1
- package/src/__tests__/task-management-tools.test.ts +3 -1
- package/src/__tests__/task-tools.test.ts +3 -1
- package/src/__tests__/terminal-sandbox.test.ts +13 -12
- package/src/__tests__/terminal-tools.test.ts +2 -0
- package/src/__tests__/tool-approval-handler.test.ts +15 -15
- package/src/__tests__/tool-execution-abort-cleanup.test.ts +2 -0
- package/src/__tests__/tool-execution-pipeline.benchmark.test.ts +2 -0
- package/src/__tests__/tool-grant-request-escalation.test.ts +7 -7
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +48 -0
- package/src/__tests__/trusted-contact-multichannel.test.ts +22 -19
- package/src/__tests__/trusted-contact-verification.test.ts +91 -0
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +2 -0
- package/src/__tests__/twitter-auth-handler.test.ts +3 -1
- package/src/__tests__/twitter-cli-routing.test.ts +3 -1
- package/src/__tests__/view-image-tool.test.ts +3 -1
- package/src/__tests__/voice-invite-redemption.test.ts +329 -0
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +7 -5
- package/src/__tests__/voice-session-bridge.test.ts +10 -10
- package/src/__tests__/work-item-output.test.ts +3 -1
- package/src/__tests__/workspace-lifecycle.test.ts +13 -2
- package/src/calls/call-controller.ts +26 -23
- package/src/calls/guardian-action-sweep.ts +10 -2
- package/src/calls/relay-server.ts +216 -27
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +3 -3
- package/src/cli.ts +12 -0
- package/src/config/agent-schema.ts +14 -3
- package/src/config/calls-schema.ts +6 -6
- package/src/config/core-schema.ts +3 -3
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/mcp-schema.ts +1 -1
- package/src/config/memory-schema.ts +27 -19
- package/src/config/schema.ts +21 -21
- package/src/config/skills-schema.ts +7 -7
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +139 -16
- package/src/daemon/handlers/config-inbox.ts +4 -4
- package/src/daemon/handlers/sessions.ts +148 -4
- package/src/daemon/ipc-contract/messages.ts +16 -0
- package/src/daemon/ipc-contract-inventory.json +1 -0
- package/src/daemon/lifecycle.ts +19 -0
- package/src/daemon/pairing-store.ts +86 -3
- package/src/daemon/session-agent-loop.ts +5 -5
- package/src/daemon/session-lifecycle.ts +25 -17
- package/src/daemon/session-memory.ts +2 -2
- package/src/daemon/session-process.ts +1 -20
- package/src/daemon/session-runtime-assembly.ts +28 -22
- package/src/daemon/session-tool-setup.ts +2 -2
- package/src/daemon/session.ts +3 -3
- package/src/memory/canonical-guardian-store.ts +63 -1
- package/src/memory/channel-guardian-store.ts +1 -0
- package/src/memory/conversation-crud.ts +7 -7
- package/src/memory/db-init.ts +4 -0
- package/src/memory/embedding-local.ts +257 -39
- package/src/memory/embedding-runtime-manager.ts +471 -0
- package/src/memory/guardian-bindings.ts +25 -1
- package/src/memory/indexer.ts +3 -3
- package/src/memory/ingress-invite-store.ts +45 -0
- package/src/memory/job-handlers/backfill.ts +16 -9
- package/src/memory/migrations/037-voice-invite-columns.ts +16 -0
- package/src/memory/migrations/index.ts +1 -0
- package/src/memory/qdrant-client.ts +31 -22
- package/src/memory/schema.ts +4 -0
- package/src/notifications/copy-composer.ts +15 -0
- package/src/runtime/access-request-helper.ts +43 -7
- package/src/runtime/actor-trust-resolver.ts +46 -50
- package/src/runtime/channel-invite-transports/voice.ts +58 -0
- package/src/runtime/channel-retry-sweep.ts +18 -6
- package/src/runtime/guardian-context-resolver.ts +38 -96
- package/src/runtime/guardian-reply-router.ts +31 -1
- package/src/runtime/ingress-service.ts +80 -3
- package/src/runtime/invite-redemption-service.ts +141 -2
- package/src/runtime/routes/channel-route-shared.ts +1 -1
- package/src/runtime/routes/channel-routes.ts +1 -1
- package/src/runtime/routes/conversation-routes.ts +2 -2
- package/src/runtime/routes/guardian-approval-interception.ts +17 -6
- package/src/runtime/routes/inbound-message-handler.ts +41 -10
- package/src/runtime/routes/ingress-routes.ts +52 -4
- package/src/runtime/routes/pairing-routes.ts +3 -0
- package/src/tools/guardian-control-plane-policy.ts +2 -2
- package/src/tools/tool-approval-handler.ts +11 -11
- package/src/tools/types.ts +2 -2
- package/src/util/logger.ts +20 -8
- package/src/util/platform.ts +10 -0
- package/src/util/voice-code.ts +29 -0
- package/src/daemon/guardian-invite-intent.ts +0 -124
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
createBinding,
|
|
86
86
|
} from '../memory/channel-guardian-store.js';
|
|
87
87
|
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
88
|
+
import { notifyGuardianOfAccessRequest } from '../runtime/access-request-helper.js';
|
|
88
89
|
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
89
90
|
|
|
90
91
|
initializeDb();
|
|
@@ -154,9 +155,10 @@ describe('non-member access request notification', () => {
|
|
|
154
155
|
expect(json.denied).toBe(true);
|
|
155
156
|
expect(json.reason).toBe('not_a_member');
|
|
156
157
|
|
|
157
|
-
// Rejection reply was delivered
|
|
158
|
+
// Rejection reply was delivered — always-notify behavior means the reply
|
|
159
|
+
// indicates the guardian will be notified, even without a same-channel binding.
|
|
158
160
|
expect(deliverReplyCalls.length).toBe(1);
|
|
159
|
-
expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("
|
|
161
|
+
expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("let them know");
|
|
160
162
|
});
|
|
161
163
|
|
|
162
164
|
test('guardian is notified when a non-member messages and a guardian binding exists', async () => {
|
|
@@ -236,8 +238,9 @@ describe('non-member access request notification', () => {
|
|
|
236
238
|
expect(pending.length).toBe(1);
|
|
237
239
|
});
|
|
238
240
|
|
|
239
|
-
test('
|
|
240
|
-
// No guardian binding — should
|
|
241
|
+
test('access request is created and signal emitted even without same-channel guardian binding', async () => {
|
|
242
|
+
// No guardian binding on any channel — access request should still be
|
|
243
|
+
// created and notification signal emitted (null guardianExternalUserId).
|
|
241
244
|
const req = buildInboundRequest();
|
|
242
245
|
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
243
246
|
const json = await resp.json() as Record<string, unknown>;
|
|
@@ -245,20 +248,55 @@ describe('non-member access request notification', () => {
|
|
|
245
248
|
expect(json.denied).toBe(true);
|
|
246
249
|
expect(json.reason).toBe('not_a_member');
|
|
247
250
|
|
|
248
|
-
// Rejection reply was
|
|
251
|
+
// Rejection reply indicates guardian was notified
|
|
249
252
|
expect(deliverReplyCalls.length).toBe(1);
|
|
253
|
+
expect((deliverReplyCalls[0].payload as Record<string, unknown>).text).toContain("let them know");
|
|
250
254
|
|
|
251
|
-
//
|
|
252
|
-
expect(emitSignalCalls.length).toBe(
|
|
255
|
+
// Notification signal was emitted
|
|
256
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
257
|
+
expect(emitSignalCalls[0].sourceEventName).toBe('ingress.access_request');
|
|
253
258
|
|
|
254
|
-
//
|
|
259
|
+
// Canonical request was created with null guardianExternalUserId
|
|
255
260
|
const pending = listCanonicalGuardianRequests({
|
|
256
261
|
status: 'pending',
|
|
257
262
|
requesterExternalUserId: 'user-unknown-456',
|
|
258
263
|
sourceChannel: 'telegram',
|
|
259
264
|
kind: 'access_request',
|
|
260
265
|
});
|
|
261
|
-
expect(pending.length).toBe(
|
|
266
|
+
expect(pending.length).toBe(1);
|
|
267
|
+
expect(pending[0].guardianExternalUserId).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('cross-channel fallback: SMS guardian binding resolves for Telegram access request', async () => {
|
|
271
|
+
// Only an SMS guardian binding exists — no Telegram binding
|
|
272
|
+
createBinding({
|
|
273
|
+
assistantId: 'self',
|
|
274
|
+
channel: 'sms',
|
|
275
|
+
guardianExternalUserId: 'guardian-sms-user',
|
|
276
|
+
guardianDeliveryChatId: 'guardian-sms-chat',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const req = buildInboundRequest();
|
|
280
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
281
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
282
|
+
|
|
283
|
+
expect(json.denied).toBe(true);
|
|
284
|
+
expect(json.reason).toBe('not_a_member');
|
|
285
|
+
|
|
286
|
+
// Notification signal emitted
|
|
287
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
288
|
+
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
289
|
+
expect(payload.guardianBindingChannel).toBe('sms');
|
|
290
|
+
|
|
291
|
+
// Canonical request has the SMS guardian's external user ID
|
|
292
|
+
const pending = listCanonicalGuardianRequests({
|
|
293
|
+
status: 'pending',
|
|
294
|
+
requesterExternalUserId: 'user-unknown-456',
|
|
295
|
+
sourceChannel: 'telegram',
|
|
296
|
+
kind: 'access_request',
|
|
297
|
+
});
|
|
298
|
+
expect(pending.length).toBe(1);
|
|
299
|
+
expect(pending[0].guardianExternalUserId).toBe('guardian-sms-user');
|
|
262
300
|
});
|
|
263
301
|
|
|
264
302
|
test('no notification when senderExternalUserId is absent', async () => {
|
|
@@ -281,3 +319,139 @@ describe('non-member access request notification', () => {
|
|
|
281
319
|
expect(emitSignalCalls.length).toBe(0);
|
|
282
320
|
});
|
|
283
321
|
});
|
|
322
|
+
|
|
323
|
+
describe('access-request-helper unit tests', () => {
|
|
324
|
+
beforeEach(() => {
|
|
325
|
+
resetState();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test('notifyGuardianOfAccessRequest returns no_sender_id when senderExternalUserId is absent', () => {
|
|
329
|
+
const result = notifyGuardianOfAccessRequest({
|
|
330
|
+
canonicalAssistantId: 'self',
|
|
331
|
+
sourceChannel: 'telegram',
|
|
332
|
+
externalChatId: 'chat-123',
|
|
333
|
+
senderExternalUserId: undefined,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(result.notified).toBe(false);
|
|
337
|
+
if (!result.notified) {
|
|
338
|
+
expect(result.reason).toBe('no_sender_id');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// No canonical request created
|
|
342
|
+
const pending = listCanonicalGuardianRequests({ status: 'pending', kind: 'access_request' });
|
|
343
|
+
expect(pending.length).toBe(0);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('notifyGuardianOfAccessRequest creates request with null guardianExternalUserId when no binding exists', () => {
|
|
347
|
+
const result = notifyGuardianOfAccessRequest({
|
|
348
|
+
canonicalAssistantId: 'self',
|
|
349
|
+
sourceChannel: 'telegram',
|
|
350
|
+
externalChatId: 'chat-123',
|
|
351
|
+
senderExternalUserId: 'unknown-user',
|
|
352
|
+
senderName: 'Bob',
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
expect(result.notified).toBe(true);
|
|
356
|
+
if (result.notified) {
|
|
357
|
+
expect(result.created).toBe(true);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const pending = listCanonicalGuardianRequests({
|
|
361
|
+
status: 'pending',
|
|
362
|
+
requesterExternalUserId: 'unknown-user',
|
|
363
|
+
kind: 'access_request',
|
|
364
|
+
});
|
|
365
|
+
expect(pending.length).toBe(1);
|
|
366
|
+
expect(pending[0].guardianExternalUserId).toBeNull();
|
|
367
|
+
|
|
368
|
+
// Signal was emitted
|
|
369
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test('notifyGuardianOfAccessRequest uses cross-channel binding when source-channel binding is missing', () => {
|
|
373
|
+
// Only SMS binding exists
|
|
374
|
+
createBinding({
|
|
375
|
+
assistantId: 'self',
|
|
376
|
+
channel: 'sms',
|
|
377
|
+
guardianExternalUserId: 'guardian-sms',
|
|
378
|
+
guardianDeliveryChatId: 'sms-chat',
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const result = notifyGuardianOfAccessRequest({
|
|
382
|
+
canonicalAssistantId: 'self',
|
|
383
|
+
sourceChannel: 'telegram',
|
|
384
|
+
externalChatId: 'tg-chat',
|
|
385
|
+
senderExternalUserId: 'unknown-tg-user',
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(result.notified).toBe(true);
|
|
389
|
+
|
|
390
|
+
const pending = listCanonicalGuardianRequests({
|
|
391
|
+
status: 'pending',
|
|
392
|
+
requesterExternalUserId: 'unknown-tg-user',
|
|
393
|
+
kind: 'access_request',
|
|
394
|
+
});
|
|
395
|
+
expect(pending.length).toBe(1);
|
|
396
|
+
expect(pending[0].guardianExternalUserId).toBe('guardian-sms');
|
|
397
|
+
|
|
398
|
+
// Signal payload includes fallback channel
|
|
399
|
+
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
400
|
+
expect(payload.guardianBindingChannel).toBe('sms');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('notifyGuardianOfAccessRequest prefers source-channel binding over cross-channel fallback', () => {
|
|
404
|
+
// Both Telegram and SMS bindings exist
|
|
405
|
+
createBinding({
|
|
406
|
+
assistantId: 'self',
|
|
407
|
+
channel: 'telegram',
|
|
408
|
+
guardianExternalUserId: 'guardian-tg',
|
|
409
|
+
guardianDeliveryChatId: 'tg-chat',
|
|
410
|
+
});
|
|
411
|
+
createBinding({
|
|
412
|
+
assistantId: 'self',
|
|
413
|
+
channel: 'sms',
|
|
414
|
+
guardianExternalUserId: 'guardian-sms',
|
|
415
|
+
guardianDeliveryChatId: 'sms-chat',
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const result = notifyGuardianOfAccessRequest({
|
|
419
|
+
canonicalAssistantId: 'self',
|
|
420
|
+
sourceChannel: 'telegram',
|
|
421
|
+
externalChatId: 'chat-123',
|
|
422
|
+
senderExternalUserId: 'unknown-user',
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
expect(result.notified).toBe(true);
|
|
426
|
+
|
|
427
|
+
const pending = listCanonicalGuardianRequests({
|
|
428
|
+
status: 'pending',
|
|
429
|
+
requesterExternalUserId: 'unknown-user',
|
|
430
|
+
kind: 'access_request',
|
|
431
|
+
});
|
|
432
|
+
expect(pending.length).toBe(1);
|
|
433
|
+
// Should use the Telegram binding, not SMS fallback
|
|
434
|
+
expect(pending[0].guardianExternalUserId).toBe('guardian-tg');
|
|
435
|
+
|
|
436
|
+
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
437
|
+
expect(payload.guardianBindingChannel).toBe('telegram');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('notifyGuardianOfAccessRequest includes requestCode in contextPayload', () => {
|
|
441
|
+
const result = notifyGuardianOfAccessRequest({
|
|
442
|
+
canonicalAssistantId: 'self',
|
|
443
|
+
sourceChannel: 'telegram',
|
|
444
|
+
externalChatId: 'chat-123',
|
|
445
|
+
senderExternalUserId: 'unknown-user',
|
|
446
|
+
senderName: 'Test User',
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
expect(result.notified).toBe(true);
|
|
450
|
+
expect(emitSignalCalls.length).toBe(1);
|
|
451
|
+
|
|
452
|
+
const payload = emitSignalCalls[0].contextPayload as Record<string, unknown>;
|
|
453
|
+
expect(payload.requestCode).toBeDefined();
|
|
454
|
+
expect(typeof payload.requestCode).toBe('string');
|
|
455
|
+
expect((payload.requestCode as string).length).toBe(6);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
@@ -151,6 +151,67 @@ describe('notification decision strategy', () => {
|
|
|
151
151
|
expect(copy.vellum!.deliveryText).toBeUndefined();
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
test('ingress.access_request template includes requester identifier', () => {
|
|
155
|
+
const signal = makeSignal({
|
|
156
|
+
sourceEventName: 'ingress.access_request',
|
|
157
|
+
contextPayload: {
|
|
158
|
+
senderIdentifier: 'Alice',
|
|
159
|
+
requestCode: 'A1B2C3',
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
164
|
+
expect(copy.vellum).toBeDefined();
|
|
165
|
+
expect(copy.vellum!.title).toBe('Access Request');
|
|
166
|
+
expect(copy.vellum!.body).toContain('Alice');
|
|
167
|
+
expect(copy.vellum!.body).toContain('requesting access');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('ingress.access_request template includes request code instruction when present', () => {
|
|
171
|
+
const signal = makeSignal({
|
|
172
|
+
sourceEventName: 'ingress.access_request',
|
|
173
|
+
contextPayload: {
|
|
174
|
+
senderIdentifier: 'Bob',
|
|
175
|
+
requestCode: 'D4E5F6',
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
180
|
+
expect(copy.vellum).toBeDefined();
|
|
181
|
+
expect(copy.vellum!.body).toContain('D4E5F6');
|
|
182
|
+
expect(copy.vellum!.body).toContain('approve');
|
|
183
|
+
expect(copy.vellum!.body).toContain('reject');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('ingress.access_request template includes invite flow instruction', () => {
|
|
187
|
+
const signal = makeSignal({
|
|
188
|
+
sourceEventName: 'ingress.access_request',
|
|
189
|
+
contextPayload: {
|
|
190
|
+
senderIdentifier: 'Charlie',
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const copy = composeFallbackCopy(signal, channels);
|
|
195
|
+
expect(copy.vellum).toBeDefined();
|
|
196
|
+
expect(copy.vellum!.body).toContain('open invite flow');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('ingress.access_request Telegram deliveryText is concise', () => {
|
|
200
|
+
const signal = makeSignal({
|
|
201
|
+
sourceEventName: 'ingress.access_request',
|
|
202
|
+
contextPayload: {
|
|
203
|
+
senderIdentifier: 'Dave',
|
|
204
|
+
requestCode: 'ABC123',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const copy = composeFallbackCopy(signal, ['telegram']);
|
|
209
|
+
expect(copy.telegram).toBeDefined();
|
|
210
|
+
expect(copy.telegram!.deliveryText).toBeDefined();
|
|
211
|
+
expect(typeof copy.telegram!.deliveryText).toBe('string');
|
|
212
|
+
expect(copy.telegram!.deliveryText!.length).toBeGreaterThan(0);
|
|
213
|
+
});
|
|
214
|
+
|
|
154
215
|
test('empty payload falls back to default text in template', () => {
|
|
155
216
|
const signal = makeSignal({
|
|
156
217
|
sourceEventName: 'guardian.question',
|
|
@@ -39,7 +39,9 @@ mock.module('../util/logger.js', () => ({
|
|
|
39
39
|
}));
|
|
40
40
|
|
|
41
41
|
mock.module('../config/loader.js', () => ({
|
|
42
|
-
getConfig: () => ({
|
|
42
|
+
getConfig: () => ({
|
|
43
|
+
ui: {},
|
|
44
|
+
}),
|
|
43
45
|
loadConfig: () => ({ ingress: { publicBaseUrl: 'https://test.example.com' } }),
|
|
44
46
|
loadRawConfig: () => ({}),
|
|
45
47
|
saveRawConfig: () => {},
|
|
@@ -151,7 +151,7 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
|
|
|
151
151
|
expect(result).not.toContain('## Starter Task Playbooks');
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
-
test('starter task playbook
|
|
154
|
+
test('starter task playbook and channel awareness both present during onboarding', () => {
|
|
155
155
|
writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
|
|
156
156
|
writeFileSync(join(TEST_DIR, 'BOOTSTRAP.md'), '# First run');
|
|
157
157
|
const result = buildSystemPrompt();
|
|
@@ -159,7 +159,6 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
|
|
|
159
159
|
const channelIdx = result.indexOf('## Channel Awareness & Trust Gating');
|
|
160
160
|
expect(starterIdx).toBeGreaterThan(-1);
|
|
161
161
|
expect(channelIdx).toBeGreaterThan(-1);
|
|
162
|
-
expect(starterIdx).toBeLessThan(channelIdx);
|
|
163
162
|
});
|
|
164
163
|
|
|
165
164
|
test('all three kickoff intents present in full system prompt during onboarding', () => {
|
|
@@ -171,9 +170,10 @@ describe('starter task playbook integration with buildSystemPrompt', () => {
|
|
|
171
170
|
expect(result).toContain('[STARTER_TASK:research_to_ui]');
|
|
172
171
|
});
|
|
173
172
|
|
|
174
|
-
test('system prompt does not contain invalid config_update surface type', () => {
|
|
173
|
+
test('system prompt does not contain invalid config_update surface type (bare)', () => {
|
|
175
174
|
writeFileSync(join(TEST_DIR, 'IDENTITY.md'), 'I am Vellum.');
|
|
176
175
|
const result = buildSystemPrompt();
|
|
177
|
-
|
|
176
|
+
// voice_config_update is a valid tool name; only bare 'config_update' surface type is invalid
|
|
177
|
+
expect(result).not.toContain('surface_type: "config_update"');
|
|
178
178
|
});
|
|
179
179
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API-level tests for the device pairing routes.
|
|
3
|
+
*
|
|
4
|
+
* Validates that handlePairingRequest correctly prevents a second device
|
|
5
|
+
* from hijacking an existing pairing request, while allowing the same
|
|
6
|
+
* device to call the endpoint idempotently.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
10
|
+
|
|
11
|
+
import { PairingStore } from '../daemon/pairing-store.js';
|
|
12
|
+
import type { PairingHandlerContext } from '../runtime/routes/pairing-routes.js';
|
|
13
|
+
import { handlePairingRequest } from '../runtime/routes/pairing-routes.js';
|
|
14
|
+
|
|
15
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
const TEST_PAIRING_ID = 'pair-test-001';
|
|
18
|
+
const TEST_SECRET = 'super-secret-value';
|
|
19
|
+
const GATEWAY_URL = 'https://gateway.test';
|
|
20
|
+
|
|
21
|
+
function makeContext(store: PairingStore): PairingHandlerContext {
|
|
22
|
+
return {
|
|
23
|
+
pairingStore: store,
|
|
24
|
+
bearerToken: 'test-bearer-token',
|
|
25
|
+
featureFlagToken: undefined,
|
|
26
|
+
pairingBroadcast: mock(() => {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makePairingRequest(overrides: Record<string, unknown> = {}): Request {
|
|
31
|
+
const body = {
|
|
32
|
+
pairingRequestId: TEST_PAIRING_ID,
|
|
33
|
+
pairingSecret: TEST_SECRET,
|
|
34
|
+
deviceId: 'device-A',
|
|
35
|
+
deviceName: 'iPhone A',
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
return new Request('http://localhost/v1/pairing/request', {
|
|
39
|
+
method: 'POST',
|
|
40
|
+
headers: { 'Content-Type': 'application/json' },
|
|
41
|
+
body: JSON.stringify(body),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Tests ────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
describe('handlePairingRequest — device binding', () => {
|
|
48
|
+
let store: PairingStore;
|
|
49
|
+
let ctx: PairingHandlerContext;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
store = new PairingStore();
|
|
53
|
+
store.start();
|
|
54
|
+
ctx = makeContext(store);
|
|
55
|
+
|
|
56
|
+
// Pre-register the pairing request (simulating QR code display)
|
|
57
|
+
store.register({
|
|
58
|
+
pairingRequestId: TEST_PAIRING_ID,
|
|
59
|
+
pairingSecret: TEST_SECRET,
|
|
60
|
+
gatewayUrl: GATEWAY_URL,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('rejects a second device attempting to pair with the same pairing ID', async () => {
|
|
65
|
+
/**
|
|
66
|
+
* Tests that once a device has initiated pairing, a different device
|
|
67
|
+
* cannot hijack the same pairing request.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
// GIVEN device A has already initiated pairing
|
|
71
|
+
const firstReq = makePairingRequest({
|
|
72
|
+
deviceId: 'device-A',
|
|
73
|
+
deviceName: 'iPhone A',
|
|
74
|
+
});
|
|
75
|
+
const firstRes = await handlePairingRequest(firstReq, ctx);
|
|
76
|
+
expect(firstRes.status).toBe(200);
|
|
77
|
+
|
|
78
|
+
// WHEN device B tries to pair with the same pairing ID and secret
|
|
79
|
+
const secondReq = makePairingRequest({
|
|
80
|
+
deviceId: 'device-B',
|
|
81
|
+
deviceName: 'iPhone B',
|
|
82
|
+
});
|
|
83
|
+
const secondRes = await handlePairingRequest(secondReq, ctx);
|
|
84
|
+
|
|
85
|
+
// THEN the request is rejected with 409 Conflict
|
|
86
|
+
expect(secondRes.status).toBe(409);
|
|
87
|
+
const body = (await secondRes.json()) as { error: { code: string; message: string } };
|
|
88
|
+
expect(body.error.code).toBe('CONFLICT');
|
|
89
|
+
expect(body.error.message).toContain('already bound to another device');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('allows the same device to call pairing request idempotently', async () => {
|
|
93
|
+
/**
|
|
94
|
+
* Tests that calling pairing request twice from the same device
|
|
95
|
+
* succeeds both times without error.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
// GIVEN device A has already initiated pairing
|
|
99
|
+
const firstReq = makePairingRequest({
|
|
100
|
+
deviceId: 'device-A',
|
|
101
|
+
deviceName: 'iPhone A',
|
|
102
|
+
});
|
|
103
|
+
const firstRes = await handlePairingRequest(firstReq, ctx);
|
|
104
|
+
expect(firstRes.status).toBe(200);
|
|
105
|
+
|
|
106
|
+
// WHEN device A calls pairing request again with the same credentials
|
|
107
|
+
const secondReq = makePairingRequest({
|
|
108
|
+
deviceId: 'device-A',
|
|
109
|
+
deviceName: 'iPhone A',
|
|
110
|
+
});
|
|
111
|
+
const secondRes = await handlePairingRequest(secondReq, ctx);
|
|
112
|
+
|
|
113
|
+
// THEN it succeeds (idempotent)
|
|
114
|
+
expect(secondRes.status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('allows the same device to retrieve token after approval', async () => {
|
|
118
|
+
/**
|
|
119
|
+
* Tests that once a pairing request is approved, the same device
|
|
120
|
+
* can call the endpoint again and receive the bearer token.
|
|
121
|
+
*/
|
|
122
|
+
|
|
123
|
+
// GIVEN device A has initiated pairing
|
|
124
|
+
const firstReq = makePairingRequest({
|
|
125
|
+
deviceId: 'device-A',
|
|
126
|
+
deviceName: 'iPhone A',
|
|
127
|
+
});
|
|
128
|
+
const firstRes = await handlePairingRequest(firstReq, ctx);
|
|
129
|
+
expect(firstRes.status).toBe(200);
|
|
130
|
+
|
|
131
|
+
// AND the pairing request has been approved
|
|
132
|
+
store.approve(TEST_PAIRING_ID, 'test-bearer-token');
|
|
133
|
+
|
|
134
|
+
// WHEN device A calls pairing request again
|
|
135
|
+
const secondReq = makePairingRequest({
|
|
136
|
+
deviceId: 'device-A',
|
|
137
|
+
deviceName: 'iPhone A',
|
|
138
|
+
});
|
|
139
|
+
const secondRes = await handlePairingRequest(secondReq, ctx);
|
|
140
|
+
|
|
141
|
+
// THEN the request succeeds (status stays approved, device matches)
|
|
142
|
+
expect(secondRes.status).toBe(200);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('rejects a different device even after the first device was approved', async () => {
|
|
146
|
+
/**
|
|
147
|
+
* Tests that a different device cannot hijack a pairing request
|
|
148
|
+
* even after the original device's request has been approved.
|
|
149
|
+
*/
|
|
150
|
+
|
|
151
|
+
// GIVEN device A has paired and been approved
|
|
152
|
+
const firstReq = makePairingRequest({
|
|
153
|
+
deviceId: 'device-A',
|
|
154
|
+
deviceName: 'iPhone A',
|
|
155
|
+
});
|
|
156
|
+
await handlePairingRequest(firstReq, ctx);
|
|
157
|
+
store.approve(TEST_PAIRING_ID, 'test-bearer-token');
|
|
158
|
+
|
|
159
|
+
// WHEN device B tries to use the same pairing request
|
|
160
|
+
const hijackReq = makePairingRequest({
|
|
161
|
+
deviceId: 'device-B',
|
|
162
|
+
deviceName: 'Attacker Phone',
|
|
163
|
+
});
|
|
164
|
+
const hijackRes = await handlePairingRequest(hijackReq, ctx);
|
|
165
|
+
|
|
166
|
+
// THEN it is rejected
|
|
167
|
+
expect(hijackRes.status).toBe(409);
|
|
168
|
+
const body = (await hijackRes.json()) as { error: { code: string; message: string } };
|
|
169
|
+
expect(body.error.code).toBe('CONFLICT');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -1,21 +1,72 @@
|
|
|
1
|
-
import { resolve } from 'node:path';
|
|
2
|
-
|
|
3
1
|
import { describe, expect, mock,test } from 'bun:test';
|
|
4
2
|
|
|
5
|
-
const retryModulePath = resolve(import.meta.dir, '../util/retry.ts');
|
|
6
|
-
|
|
7
3
|
mock.module('../util/logger.js', () => ({
|
|
8
4
|
getLogger: () =>
|
|
9
5
|
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
10
6
|
isDebug: () => false,
|
|
11
7
|
}));
|
|
12
8
|
|
|
13
|
-
// Only mock sleep so retries complete instantly; keep real retry logic
|
|
14
|
-
mock.module
|
|
15
|
-
|
|
9
|
+
// Only mock sleep so retries complete instantly; keep real retry logic.
|
|
10
|
+
// NOTE: We must NOT use `await import()` inside mock.module — it deadlocks
|
|
11
|
+
// bun's module resolver. Instead, inline the real exports and only replace sleep.
|
|
12
|
+
mock.module('../util/retry.js', () => {
|
|
13
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
14
|
+
const DEFAULT_BASE_DELAY_MS = 1000;
|
|
15
|
+
|
|
16
|
+
function computeRetryDelay(attempt: number, baseDelayMs = DEFAULT_BASE_DELAY_MS): number {
|
|
17
|
+
const cap = baseDelayMs * Math.pow(2, attempt);
|
|
18
|
+
const half = cap / 2;
|
|
19
|
+
return half + Math.random() * half;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseRetryAfterMs(value: string): number | undefined {
|
|
23
|
+
const seconds = Number(value);
|
|
24
|
+
if (!isNaN(seconds)) return seconds * 1000;
|
|
25
|
+
const dateMs = Date.parse(value);
|
|
26
|
+
if (!isNaN(dateMs)) return Math.max(0, dateMs - Date.now());
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getHttpRetryDelay(
|
|
31
|
+
response: Response,
|
|
32
|
+
attempt: number,
|
|
33
|
+
baseDelayMs = DEFAULT_BASE_DELAY_MS,
|
|
34
|
+
): number {
|
|
35
|
+
const retryAfter = response.headers.get('retry-after');
|
|
36
|
+
if (retryAfter) {
|
|
37
|
+
const parsed = parseRetryAfterMs(retryAfter);
|
|
38
|
+
if (parsed !== undefined) return parsed;
|
|
39
|
+
}
|
|
40
|
+
const effectiveBase = attempt === 0 ? baseDelayMs * 2 : baseDelayMs;
|
|
41
|
+
return Math.max(baseDelayMs, computeRetryDelay(attempt, effectiveBase));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isRetryableStatus(status: number): boolean {
|
|
45
|
+
return status === 429 || status >= 500;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isRetryableNetworkError(error: unknown): boolean {
|
|
49
|
+
if (!(error instanceof Error)) return false;
|
|
50
|
+
const retryableCodes = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EPIPE']);
|
|
51
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
52
|
+
if (code && retryableCodes.has(code)) return true;
|
|
53
|
+
if (error.cause instanceof Error) {
|
|
54
|
+
const causeCode = (error.cause as NodeJS.ErrnoException).code;
|
|
55
|
+
if (causeCode && retryableCodes.has(causeCode)) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
16
60
|
return {
|
|
17
|
-
|
|
61
|
+
DEFAULT_MAX_RETRIES,
|
|
62
|
+
DEFAULT_BASE_DELAY_MS,
|
|
63
|
+
computeRetryDelay,
|
|
64
|
+
parseRetryAfterMs,
|
|
65
|
+
getHttpRetryDelay,
|
|
66
|
+
isRetryableStatus,
|
|
67
|
+
isRetryableNetworkError,
|
|
18
68
|
sleep: () => Promise.resolve(),
|
|
69
|
+
abortableSleep: () => Promise.resolve(),
|
|
19
70
|
};
|
|
20
71
|
});
|
|
21
72
|
|
|
@@ -17,6 +17,8 @@ mock.module('../util/logger.js', () => ({
|
|
|
17
17
|
|
|
18
18
|
mock.module('../config/loader.js', () => ({
|
|
19
19
|
getConfig: () => ({
|
|
20
|
+
ui: {},
|
|
21
|
+
|
|
20
22
|
daemon: { standaloneRecording: true },
|
|
21
23
|
provider: 'mock-provider',
|
|
22
24
|
permissions: { mode: 'legacy' },
|
|
@@ -49,6 +51,15 @@ const mockMessages: Array<{ id: string; role: string; content: string }> = [];
|
|
|
49
51
|
let mockMessageIdCounter = 0;
|
|
50
52
|
|
|
51
53
|
mock.module('../memory/conversation-store.js', () => ({
|
|
54
|
+
getConversationThreadType: () => 'default',
|
|
55
|
+
setConversationOriginChannelIfUnset: () => {},
|
|
56
|
+
updateConversationContextWindow: () => {},
|
|
57
|
+
deleteMessageById: () => {},
|
|
58
|
+
updateConversationTitle: () => {},
|
|
59
|
+
updateConversationUsage: () => {},
|
|
60
|
+
provenanceFromGuardianContext: () => ({ source: 'user', guardianContext: undefined }),
|
|
61
|
+
getConversationOriginInterface: () => null,
|
|
62
|
+
getConversationOriginChannel: () => null,
|
|
52
63
|
getMessages: () => mockMessages,
|
|
53
64
|
addMessage: (_convId: string, role: string, content: string) => {
|
|
54
65
|
const msg = { id: `msg-${++mockMessageIdCounter}`, role, content };
|