@vellumai/assistant 0.3.19 → 0.3.21
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 +151 -15
- package/Dockerfile +1 -0
- package/README.md +40 -4
- package/bun.lock +139 -2
- package/docs/architecture/integrations.md +7 -11
- package/package.json +2 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +54 -0
- package/src/__tests__/approval-primitive.test.ts +540 -0
- package/src/__tests__/assistant-feature-flag-guard.test.ts +206 -0
- package/src/__tests__/assistant-feature-flag-guardrails.test.ts +198 -0
- package/src/__tests__/assistant-feature-flags-integration.test.ts +272 -0
- package/src/__tests__/call-controller.test.ts +439 -108
- package/src/__tests__/channel-invite-transport.test.ts +264 -0
- package/src/__tests__/cli.test.ts +42 -1
- package/src/__tests__/config-schema.test.ts +11 -127
- package/src/__tests__/config-watcher.test.ts +0 -8
- package/src/__tests__/daemon-lifecycle.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +8 -2
- package/src/__tests__/diff.test.ts +22 -0
- package/src/__tests__/guardian-action-copy-generator.test.ts +5 -0
- package/src/__tests__/guardian-action-grant-mint-consume.test.ts +300 -32
- package/src/__tests__/guardian-action-late-reply.test.ts +546 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +774 -0
- package/src/__tests__/guardian-control-plane-policy.test.ts +36 -3
- package/src/__tests__/guardian-dispatch.test.ts +124 -0
- package/src/__tests__/guardian-grant-minting.test.ts +6 -17
- package/src/__tests__/inbound-invite-redemption.test.ts +367 -0
- package/src/__tests__/invite-redemption-service.test.ts +306 -0
- package/src/__tests__/ipc-snapshot.test.ts +57 -0
- package/src/__tests__/notification-decision-fallback.test.ts +88 -0
- package/src/__tests__/sandbox-diagnostics.test.ts +6 -249
- package/src/__tests__/sandbox-host-parity.test.ts +6 -13
- package/src/__tests__/scoped-approval-grants.test.ts +6 -6
- package/src/__tests__/scoped-grant-security-matrix.test.ts +5 -4
- package/src/__tests__/script-proxy-session-manager.test.ts +1 -19
- package/src/__tests__/session-load-history-repair.test.ts +169 -2
- package/src/__tests__/session-runtime-assembly.test.ts +33 -5
- package/src/__tests__/skill-feature-flags-integration.test.ts +171 -0
- package/src/__tests__/skill-feature-flags.test.ts +188 -0
- package/src/__tests__/skill-load-feature-flag.test.ts +141 -0
- package/src/__tests__/skill-mirror-parity.test.ts +1 -0
- package/src/__tests__/skill-projection-feature-flag.test.ts +363 -0
- package/src/__tests__/system-prompt.test.ts +1 -1
- package/src/__tests__/terminal-sandbox.test.ts +142 -9
- package/src/__tests__/terminal-tools.test.ts +2 -93
- package/src/__tests__/thread-seed-composer.test.ts +18 -0
- package/src/__tests__/tool-approval-handler.test.ts +350 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +8 -10
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +46 -84
- package/src/agent/loop.ts +36 -1
- package/src/approvals/approval-primitive.ts +381 -0
- package/src/approvals/guardian-decision-primitive.ts +191 -0
- package/src/calls/call-controller.ts +252 -209
- package/src/calls/call-domain.ts +44 -6
- package/src/calls/guardian-dispatch.ts +48 -0
- package/src/calls/types.ts +1 -1
- package/src/calls/voice-session-bridge.ts +46 -30
- package/src/cli/core-commands.ts +0 -4
- package/src/cli/mcp.ts +58 -0
- package/src/cli.ts +76 -34
- package/src/config/__tests__/feature-flag-registry-guard.test.ts +179 -0
- package/src/config/assistant-feature-flags.ts +162 -0
- package/src/config/bundled-skills/api-mapping/icon.svg +18 -0
- package/src/config/bundled-skills/messaging/TOOLS.json +30 -0
- package/src/config/bundled-skills/messaging/tools/slack-delete-message.ts +24 -0
- package/src/config/bundled-skills/notifications/SKILL.md +1 -1
- package/src/config/bundled-skills/reminder/SKILL.md +49 -2
- package/src/config/bundled-skills/time-based-actions/SKILL.md +49 -2
- package/src/config/bundled-skills/voice-setup/SKILL.md +122 -0
- package/src/config/core-schema.ts +1 -1
- package/src/config/env-registry.ts +10 -0
- package/src/config/feature-flag-registry.json +61 -0
- package/src/config/loader.ts +22 -1
- package/src/config/mcp-schema.ts +46 -0
- package/src/config/sandbox-schema.ts +0 -39
- package/src/config/schema.ts +18 -2
- package/src/config/skill-state.ts +34 -0
- package/src/config/skills-schema.ts +0 -1
- package/src/config/skills.ts +9 -0
- package/src/config/system-prompt.ts +110 -46
- package/src/config/templates/SOUL.md +1 -1
- package/src/config/types.ts +19 -1
- package/src/config/vellum-skills/catalog.json +1 -1
- package/src/config/vellum-skills/guardian-verify-setup/SKILL.md +1 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +1 -1
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -5
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +105 -3
- package/src/config/vellum-skills/twilio-setup/SKILL.md +1 -1
- package/src/daemon/config-watcher.ts +0 -1
- package/src/daemon/daemon-control.ts +1 -1
- package/src/daemon/guardian-invite-intent.ts +124 -0
- package/src/daemon/handlers/avatar.ts +68 -0
- package/src/daemon/handlers/browser.ts +2 -2
- package/src/daemon/handlers/guardian-actions.ts +120 -0
- package/src/daemon/handlers/index.ts +4 -0
- package/src/daemon/handlers/sessions.ts +19 -0
- package/src/daemon/handlers/shared.ts +3 -1
- package/src/daemon/install-cli-launchers.ts +58 -13
- package/src/daemon/ipc-contract/guardian-actions.ts +53 -0
- package/src/daemon/ipc-contract/sessions.ts +8 -2
- package/src/daemon/ipc-contract/settings.ts +25 -2
- package/src/daemon/ipc-contract-inventory.json +10 -0
- package/src/daemon/ipc-contract.ts +4 -0
- package/src/daemon/lifecycle.ts +14 -2
- package/src/daemon/main.ts +1 -0
- package/src/daemon/providers-setup.ts +26 -1
- package/src/daemon/server.ts +1 -0
- package/src/daemon/session-lifecycle.ts +52 -7
- package/src/daemon/session-memory.ts +45 -0
- package/src/daemon/session-process.ts +258 -432
- package/src/daemon/session-runtime-assembly.ts +12 -0
- package/src/daemon/session-skill-tools.ts +14 -1
- package/src/daemon/session-tool-setup.ts +5 -0
- package/src/daemon/session.ts +11 -0
- package/src/daemon/shutdown-handlers.ts +11 -0
- package/src/daemon/tool-side-effects.ts +35 -9
- package/src/index.ts +2 -2
- package/src/mcp/client.ts +152 -0
- package/src/mcp/manager.ts +139 -0
- package/src/memory/conversation-display-order-migration.ts +44 -0
- package/src/memory/conversation-queries.ts +2 -0
- package/src/memory/conversation-store.ts +91 -0
- package/src/memory/db-init.ts +5 -1
- package/src/memory/embedding-local.ts +13 -8
- package/src/memory/guardian-action-store.ts +125 -2
- package/src/memory/ingress-invite-store.ts +95 -1
- package/src/memory/migrations/035-guardian-action-supersession.ts +23 -0
- package/src/memory/migrations/index.ts +2 -1
- package/src/memory/schema.ts +5 -1
- package/src/memory/scoped-approval-grants.ts +14 -5
- package/src/messaging/providers/slack/client.ts +12 -0
- package/src/messaging/providers/slack/types.ts +5 -0
- package/src/notifications/decision-engine.ts +49 -12
- package/src/notifications/emit-signal.ts +7 -0
- package/src/notifications/signal.ts +7 -0
- package/src/notifications/thread-seed-composer.ts +2 -1
- package/src/runtime/channel-approval-types.ts +16 -6
- package/src/runtime/channel-approvals.ts +19 -15
- package/src/runtime/channel-invite-transport.ts +85 -0
- package/src/runtime/channel-invite-transports/telegram.ts +105 -0
- package/src/runtime/guardian-action-grant-minter.ts +92 -35
- package/src/runtime/guardian-action-message-composer.ts +30 -0
- package/src/runtime/guardian-decision-types.ts +91 -0
- package/src/runtime/http-server.ts +23 -1
- package/src/runtime/ingress-service.ts +22 -0
- package/src/runtime/invite-redemption-service.ts +181 -0
- package/src/runtime/invite-redemption-templates.ts +39 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/guardian-action-routes.ts +206 -0
- package/src/runtime/routes/guardian-approval-interception.ts +66 -190
- package/src/runtime/routes/identity-routes.ts +73 -0
- package/src/runtime/routes/inbound-message-handler.ts +486 -394
- package/src/runtime/routes/pairing-routes.ts +4 -0
- package/src/security/encrypted-store.ts +31 -17
- package/src/security/keychain.ts +176 -2
- package/src/security/secure-keys.ts +97 -0
- package/src/security/tool-approval-digest.ts +1 -1
- package/src/tools/browser/browser-execution.ts +2 -2
- package/src/tools/browser/browser-manager.ts +46 -32
- package/src/tools/browser/browser-screencast.ts +2 -2
- package/src/tools/calls/call-start.ts +1 -1
- package/src/tools/executor.ts +22 -17
- package/src/tools/mcp/mcp-tool-factory.ts +100 -0
- package/src/tools/network/script-proxy/session-manager.ts +1 -5
- package/src/tools/registry.ts +64 -1
- package/src/tools/skills/load.ts +22 -8
- package/src/tools/system/avatar-generator.ts +119 -0
- package/src/tools/system/navigate-settings.ts +65 -0
- package/src/tools/system/open-system-settings.ts +75 -0
- package/src/tools/system/voice-config.ts +121 -32
- package/src/tools/terminal/backends/native.ts +40 -19
- package/src/tools/terminal/backends/types.ts +3 -3
- package/src/tools/terminal/parser.ts +1 -1
- package/src/tools/terminal/sandbox-diagnostics.ts +6 -87
- package/src/tools/terminal/sandbox.ts +1 -12
- package/src/tools/terminal/shell.ts +3 -31
- package/src/tools/tool-approval-handler.ts +141 -3
- package/src/tools/tool-manifest.ts +6 -0
- package/src/tools/types.ts +10 -2
- package/src/util/diff.ts +36 -13
- package/Dockerfile.sandbox +0 -5
- package/src/__tests__/doordash-client.test.ts +0 -187
- package/src/__tests__/doordash-session.test.ts +0 -154
- package/src/__tests__/signup-e2e.test.ts +0 -354
- package/src/__tests__/terminal-sandbox-docker.test.ts +0 -1065
- package/src/__tests__/terminal-sandbox.integration.test.ts +0 -180
- package/src/cli/doordash.ts +0 -1057
- package/src/config/bundled-skills/doordash/SKILL.md +0 -163
- package/src/config/templates/LOOKS.md +0 -25
- package/src/doordash/cart-queries.ts +0 -787
- package/src/doordash/client.ts +0 -1016
- package/src/doordash/order-queries.ts +0 -85
- package/src/doordash/queries.ts +0 -13
- package/src/doordash/query-extractor.ts +0 -94
- package/src/doordash/search-queries.ts +0 -203
- package/src/doordash/session.ts +0 -84
- package/src/doordash/store-queries.ts +0 -246
- package/src/doordash/types.ts +0 -367
- package/src/tools/terminal/backends/docker.ts +0 -379
|
@@ -484,15 +484,15 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
484
484
|
expect(result.content).toBe('ok');
|
|
485
485
|
});
|
|
486
486
|
|
|
487
|
-
test('non-guardian invocation of unrelated
|
|
487
|
+
test('non-guardian invocation of unrelated bash command is blocked by guardian approval gate', async () => {
|
|
488
488
|
const executor = new ToolExecutor(makePrompter());
|
|
489
489
|
const result = await executor.execute(
|
|
490
490
|
'bash',
|
|
491
491
|
{ command: 'curl http://localhost:3000/v1/messages' },
|
|
492
492
|
makeContext({ guardianActorRole: 'non-guardian' }),
|
|
493
493
|
);
|
|
494
|
-
expect(result.isError).toBe(
|
|
495
|
-
expect(result.content).
|
|
494
|
+
expect(result.isError).toBe(true);
|
|
495
|
+
expect(result.content).toContain('requires guardian approval');
|
|
496
496
|
});
|
|
497
497
|
|
|
498
498
|
test('non-guardian invocation of unrelated tool is unaffected', async () => {
|
|
@@ -579,4 +579,37 @@ describe('ToolExecutor guardian-only policy gate', () => {
|
|
|
579
579
|
expect(result.content).toContain('restricted to guardian users');
|
|
580
580
|
}
|
|
581
581
|
});
|
|
582
|
+
|
|
583
|
+
test('non-guardian actor is blocked from host read tools (host execution)', async () => {
|
|
584
|
+
const executor = new ToolExecutor(makePrompter());
|
|
585
|
+
const result = await executor.execute(
|
|
586
|
+
'host_file_read',
|
|
587
|
+
{ path: '/Users/noaflaherty/.ssh/config' },
|
|
588
|
+
makeContext({ guardianActorRole: 'non-guardian' }),
|
|
589
|
+
);
|
|
590
|
+
expect(result.isError).toBe(true);
|
|
591
|
+
expect(result.content).toContain('requires guardian approval');
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
test('unverified channel actor is blocked from side-effect tools', async () => {
|
|
595
|
+
const executor = new ToolExecutor(makePrompter());
|
|
596
|
+
const result = await executor.execute(
|
|
597
|
+
'reminder_create',
|
|
598
|
+
{ fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
|
|
599
|
+
makeContext({ guardianActorRole: 'unverified_channel' }),
|
|
600
|
+
);
|
|
601
|
+
expect(result.isError).toBe(true);
|
|
602
|
+
expect(result.content).toContain('verified channel identity');
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test('guardian actor can execute side-effect tools', async () => {
|
|
606
|
+
const executor = new ToolExecutor(makePrompter());
|
|
607
|
+
const result = await executor.execute(
|
|
608
|
+
'reminder_create',
|
|
609
|
+
{ fire_at: '2026-02-27T12:00:00-05:00', label: 'test', message: 'hello' },
|
|
610
|
+
makeContext({ guardianActorRole: 'guardian' }),
|
|
611
|
+
);
|
|
612
|
+
expect(result.isError).toBe(false);
|
|
613
|
+
expect(result.content).toBe('ok');
|
|
614
|
+
});
|
|
582
615
|
});
|
|
@@ -515,4 +515,128 @@ describe('guardian-dispatch', () => {
|
|
|
515
515
|
const secondPayload = (emitCalls[0] as Record<string, unknown>).contextPayload as Record<string, unknown>;
|
|
516
516
|
expect(secondPayload.activeGuardianRequestCount).toBe(2);
|
|
517
517
|
});
|
|
518
|
+
|
|
519
|
+
test('second guardian question in same call session passes conversationAffinityHint with first conversation ID', async () => {
|
|
520
|
+
const convId = 'conv-dispatch-affinity-1';
|
|
521
|
+
ensureConversation(convId);
|
|
522
|
+
|
|
523
|
+
const sharedConversationId = 'conv-affinity-guardian';
|
|
524
|
+
|
|
525
|
+
const session = createCallSession({
|
|
526
|
+
conversationId: convId,
|
|
527
|
+
provider: 'twilio',
|
|
528
|
+
fromNumber: '+15550001111',
|
|
529
|
+
toNumber: '+15550002222',
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// First dispatch — no affinity hint expected (no prior delivery exists)
|
|
533
|
+
const pq1 = createPendingQuestion(session.id, 'First question');
|
|
534
|
+
mockEmitResult = {
|
|
535
|
+
signalId: 'sig-affinity-1',
|
|
536
|
+
deduplicated: false,
|
|
537
|
+
dispatched: true,
|
|
538
|
+
reason: 'ok',
|
|
539
|
+
deliveryResults: [
|
|
540
|
+
{
|
|
541
|
+
channel: 'vellum',
|
|
542
|
+
destination: 'vellum',
|
|
543
|
+
status: 'sent',
|
|
544
|
+
conversationId: sharedConversationId,
|
|
545
|
+
},
|
|
546
|
+
],
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
await dispatchGuardianQuestion({
|
|
550
|
+
callSessionId: session.id,
|
|
551
|
+
conversationId: convId,
|
|
552
|
+
assistantId: 'self',
|
|
553
|
+
pendingQuestion: pq1,
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
const firstParams = emitCalls[0] as Record<string, unknown>;
|
|
557
|
+
// First dispatch should not have an affinity hint
|
|
558
|
+
expect(firstParams.conversationAffinityHint).toBeUndefined();
|
|
559
|
+
|
|
560
|
+
// Second dispatch — should carry the affinity hint from the first delivery
|
|
561
|
+
emitCalls.length = 0;
|
|
562
|
+
const pq2 = createPendingQuestion(session.id, 'Second question');
|
|
563
|
+
mockEmitResult = {
|
|
564
|
+
signalId: 'sig-affinity-2',
|
|
565
|
+
deduplicated: false,
|
|
566
|
+
dispatched: true,
|
|
567
|
+
reason: 'ok',
|
|
568
|
+
deliveryResults: [
|
|
569
|
+
{
|
|
570
|
+
channel: 'vellum',
|
|
571
|
+
destination: 'vellum',
|
|
572
|
+
status: 'sent',
|
|
573
|
+
conversationId: sharedConversationId,
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
await dispatchGuardianQuestion({
|
|
579
|
+
callSessionId: session.id,
|
|
580
|
+
conversationId: convId,
|
|
581
|
+
assistantId: 'self',
|
|
582
|
+
pendingQuestion: pq2,
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const secondParams = emitCalls[0] as Record<string, unknown>;
|
|
586
|
+
expect(secondParams.conversationAffinityHint).toEqual({
|
|
587
|
+
vellum: sharedConversationId,
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test('third guardian question in same call session also carries affinity hint', async () => {
|
|
592
|
+
const convId = 'conv-dispatch-affinity-2';
|
|
593
|
+
ensureConversation(convId);
|
|
594
|
+
|
|
595
|
+
const sharedConversationId = 'conv-affinity-triple';
|
|
596
|
+
|
|
597
|
+
const session = createCallSession({
|
|
598
|
+
conversationId: convId,
|
|
599
|
+
provider: 'twilio',
|
|
600
|
+
fromNumber: '+15550001111',
|
|
601
|
+
toNumber: '+15550002222',
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// Dispatch three guardian questions in the same call session
|
|
605
|
+
for (let i = 0; i < 3; i++) {
|
|
606
|
+
emitCalls.length = 0;
|
|
607
|
+
const pq = createPendingQuestion(session.id, `Question ${i + 1}`);
|
|
608
|
+
mockEmitResult = {
|
|
609
|
+
signalId: `sig-triple-${i}`,
|
|
610
|
+
deduplicated: false,
|
|
611
|
+
dispatched: true,
|
|
612
|
+
reason: 'ok',
|
|
613
|
+
deliveryResults: [
|
|
614
|
+
{
|
|
615
|
+
channel: 'vellum',
|
|
616
|
+
destination: 'vellum',
|
|
617
|
+
status: 'sent',
|
|
618
|
+
conversationId: sharedConversationId,
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
await dispatchGuardianQuestion({
|
|
624
|
+
callSessionId: session.id,
|
|
625
|
+
conversationId: convId,
|
|
626
|
+
assistantId: 'self',
|
|
627
|
+
pendingQuestion: pq,
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
const params = emitCalls[0] as Record<string, unknown>;
|
|
631
|
+
if (i === 0) {
|
|
632
|
+
// First dispatch — no affinity hint
|
|
633
|
+
expect(params.conversationAffinityHint).toBeUndefined();
|
|
634
|
+
} else {
|
|
635
|
+
// Subsequent dispatches — affinity hint points to the shared conversation
|
|
636
|
+
expect(params.conversationAffinityHint).toEqual({
|
|
637
|
+
vellum: sharedConversationId,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
});
|
|
518
642
|
});
|
|
@@ -37,24 +37,23 @@ mock.module('../util/logger.js', () => ({
|
|
|
37
37
|
}),
|
|
38
38
|
}));
|
|
39
39
|
|
|
40
|
+
import { GRANT_TTL_MS } from '../approvals/guardian-decision-primitive.js';
|
|
40
41
|
import type { Session } from '../daemon/session.js';
|
|
41
42
|
import {
|
|
42
43
|
createApprovalRequest,
|
|
43
|
-
createBinding,
|
|
44
|
-
getAllPendingApprovalsByGuardianChat,
|
|
45
44
|
type GuardianApprovalRequest,
|
|
46
45
|
} from '../memory/channel-guardian-store.js';
|
|
47
|
-
import { initializeDb, resetDb } from '../memory/db.js';
|
|
48
|
-
import * as scopedGrantStore from '../memory/scoped-approval-grants.js';
|
|
49
|
-
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
46
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
50
47
|
import * as approvalMessageComposer from '../runtime/approval-message-composer.js';
|
|
51
48
|
import * as gatewayClient from '../runtime/gateway-client.js';
|
|
52
49
|
import * as pendingInteractions from '../runtime/pending-interactions.js';
|
|
50
|
+
import type { GuardianContext } from '../runtime/routes/channel-route-shared.js';
|
|
53
51
|
import {
|
|
54
52
|
handleApprovalInterception,
|
|
55
|
-
GRANT_TTL_MS,
|
|
56
53
|
} from '../runtime/routes/guardian-approval-interception.js';
|
|
57
|
-
import
|
|
54
|
+
import { computeToolApprovalDigest } from '../security/tool-approval-digest.js';
|
|
55
|
+
|
|
56
|
+
import '../memory/scoped-approval-grants.js';
|
|
58
57
|
|
|
59
58
|
initializeDb();
|
|
60
59
|
|
|
@@ -78,7 +77,6 @@ const TOOL_INPUT = { command: 'rm -rf /tmp/test' };
|
|
|
78
77
|
|
|
79
78
|
function resetTables(): void {
|
|
80
79
|
try {
|
|
81
|
-
const { getDb } = require('../memory/db.js');
|
|
82
80
|
const db = getDb();
|
|
83
81
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
84
82
|
db.run('DELETE FROM scoped_approval_grants');
|
|
@@ -144,16 +142,8 @@ function makeGuardianContext(): GuardianContext {
|
|
|
144
142
|
};
|
|
145
143
|
}
|
|
146
144
|
|
|
147
|
-
function makeNonGuardianContext(): GuardianContext {
|
|
148
|
-
return {
|
|
149
|
-
actorRole: 'non-guardian',
|
|
150
|
-
denialReason: undefined,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
145
|
function countGrants(): number {
|
|
155
146
|
try {
|
|
156
|
-
const { getDb } = require('../memory/db.js');
|
|
157
147
|
const db = getDb();
|
|
158
148
|
const row = db.$client.prepare('SELECT count(*) as cnt FROM scoped_approval_grants').get() as { cnt: number };
|
|
159
149
|
return row.cnt;
|
|
@@ -164,7 +154,6 @@ function countGrants(): number {
|
|
|
164
154
|
|
|
165
155
|
function getLatestGrant(): Record<string, unknown> | null {
|
|
166
156
|
try {
|
|
167
|
-
const { getDb } = require('../memory/db.js');
|
|
168
157
|
const db = getDb();
|
|
169
158
|
const row = db.$client.prepare('SELECT * FROM scoped_approval_grants ORDER BY created_at DESC LIMIT 1').get();
|
|
170
159
|
return (row as Record<string, unknown>) ?? null;
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for the inbound invite redemption intercept.
|
|
3
|
+
*
|
|
4
|
+
* Validates that non-members with valid `/start iv_<token>` payloads are
|
|
5
|
+
* granted access without guardian approval, and that invalid/expired/revoked
|
|
6
|
+
* tokens produce the correct deterministic refusal messages.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Test isolation: in-memory SQLite via temp directory
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
const testDir = mkdtempSync(join(tmpdir(), 'inbound-invite-redemption-test-'));
|
|
19
|
+
|
|
20
|
+
mock.module('../util/platform.js', () => ({
|
|
21
|
+
getRootDir: () => testDir,
|
|
22
|
+
getDataDir: () => testDir,
|
|
23
|
+
isMacOS: () => process.platform === 'darwin',
|
|
24
|
+
isLinux: () => process.platform === 'linux',
|
|
25
|
+
isWindows: () => process.platform === 'win32',
|
|
26
|
+
getSocketPath: () => join(testDir, 'test.sock'),
|
|
27
|
+
getPidPath: () => join(testDir, 'test.pid'),
|
|
28
|
+
getDbPath: () => join(testDir, 'test.db'),
|
|
29
|
+
getLogPath: () => join(testDir, 'test.log'),
|
|
30
|
+
ensureDataDir: () => {},
|
|
31
|
+
normalizeAssistantId: (id: string) => id === 'self' ? 'self' : id,
|
|
32
|
+
readHttpToken: () => 'test-bearer-token',
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
mock.module('../util/logger.js', () => ({
|
|
36
|
+
getLogger: () => new Proxy({} as Record<string, unknown>, {
|
|
37
|
+
get: () => () => {},
|
|
38
|
+
}),
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
mock.module('../security/secret-ingress.js', () => ({
|
|
42
|
+
checkIngressForSecrets: () => ({ blocked: false }),
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module('../config/env.js', () => ({
|
|
46
|
+
getGatewayInternalBaseUrl: () => 'http://127.0.0.1:7830',
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
// Mock the credential metadata store so the Telegram transport adapter
|
|
50
|
+
// resolves without touching the filesystem.
|
|
51
|
+
mock.module('../tools/credentials/metadata-store.js', () => ({
|
|
52
|
+
getCredentialMetadata: () => undefined,
|
|
53
|
+
upsertCredentialMetadata: () => {},
|
|
54
|
+
deleteCredentialMetadata: () => {},
|
|
55
|
+
listCredentialMetadata: () => [],
|
|
56
|
+
}));
|
|
57
|
+
|
|
58
|
+
const emitSignalCalls: Array<Record<string, unknown>> = [];
|
|
59
|
+
mock.module('../notifications/emit-signal.js', () => ({
|
|
60
|
+
emitNotificationSignal: async (params: Record<string, unknown>) => {
|
|
61
|
+
emitSignalCalls.push(params);
|
|
62
|
+
return {
|
|
63
|
+
signalId: 'mock-signal-id',
|
|
64
|
+
deduplicated: false,
|
|
65
|
+
dispatched: true,
|
|
66
|
+
reason: 'mock',
|
|
67
|
+
deliveryResults: [],
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
const deliverReplyCalls: Array<{ url: string; payload: Record<string, unknown> }> = [];
|
|
73
|
+
mock.module('../runtime/gateway-client.js', () => ({
|
|
74
|
+
deliverChannelReply: async (url: string, payload: Record<string, unknown>) => {
|
|
75
|
+
deliverReplyCalls.push({ url, payload });
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
mock.module('../runtime/approval-message-composer.js', () => ({
|
|
80
|
+
composeApprovalMessage: () => 'mock approval message',
|
|
81
|
+
composeApprovalMessageGenerative: async () => 'mock generative message',
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
import { getDb, initializeDb, resetDb } from '../memory/db.js';
|
|
85
|
+
import { createInvite, revokeInvite } from '../memory/ingress-invite-store.js';
|
|
86
|
+
import { findMember, upsertMember } from '../memory/ingress-member-store.js';
|
|
87
|
+
import { handleChannelInbound } from '../runtime/routes/channel-routes.js';
|
|
88
|
+
|
|
89
|
+
initializeDb();
|
|
90
|
+
|
|
91
|
+
afterAll(() => {
|
|
92
|
+
resetDb();
|
|
93
|
+
try { rmSync(testDir, { recursive: true }); } catch { /* best effort */ }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Helpers
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
const TEST_BEARER_TOKEN = 'test-token';
|
|
101
|
+
let msgCounter = 0;
|
|
102
|
+
|
|
103
|
+
function resetState(): void {
|
|
104
|
+
const db = getDb();
|
|
105
|
+
db.run('DELETE FROM assistant_ingress_members');
|
|
106
|
+
db.run('DELETE FROM assistant_ingress_invites');
|
|
107
|
+
db.run('DELETE FROM channel_inbound_events');
|
|
108
|
+
db.run('DELETE FROM conversations');
|
|
109
|
+
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
110
|
+
db.run('DELETE FROM channel_guardian_bindings');
|
|
111
|
+
db.run('DELETE FROM notification_events');
|
|
112
|
+
emitSignalCalls.length = 0;
|
|
113
|
+
deliverReplyCalls.length = 0;
|
|
114
|
+
msgCounter = 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function buildInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
118
|
+
msgCounter++;
|
|
119
|
+
const body: Record<string, unknown> = {
|
|
120
|
+
sourceChannel: 'telegram',
|
|
121
|
+
interface: 'telegram',
|
|
122
|
+
externalChatId: 'chat-invite-test',
|
|
123
|
+
externalMessageId: `msg-invite-${Date.now()}-${msgCounter}`,
|
|
124
|
+
content: '/start iv_sometoken',
|
|
125
|
+
senderExternalUserId: 'user-invite-123',
|
|
126
|
+
senderName: 'Invite User',
|
|
127
|
+
senderUsername: 'invite_user',
|
|
128
|
+
replyCallbackUrl: 'http://localhost:7830/deliver/telegram',
|
|
129
|
+
sourceMetadata: {
|
|
130
|
+
commandIntent: { type: 'start', payload: 'iv_sometoken' },
|
|
131
|
+
},
|
|
132
|
+
...overrides,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return new Request('http://localhost:8080/channels/inbound', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
140
|
+
},
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Build a request with a specific invite token, using the structured
|
|
147
|
+
* commandIntent that the gateway produces for `/start <payload>`.
|
|
148
|
+
*/
|
|
149
|
+
function buildInviteRequest(rawToken: string, overrides: Record<string, unknown> = {}): Request {
|
|
150
|
+
return buildInboundRequest({
|
|
151
|
+
content: `/start iv_${rawToken}`,
|
|
152
|
+
sourceMetadata: {
|
|
153
|
+
commandIntent: { type: 'start', payload: `iv_${rawToken}` },
|
|
154
|
+
},
|
|
155
|
+
...overrides,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Tests
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
describe('inbound invite redemption intercept', () => {
|
|
164
|
+
beforeEach(resetState);
|
|
165
|
+
|
|
166
|
+
test('non-member with valid invite token becomes active member without guardian approval', async () => {
|
|
167
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
168
|
+
|
|
169
|
+
const req = buildInviteRequest(rawToken);
|
|
170
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
171
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
172
|
+
|
|
173
|
+
expect(json.accepted).toBe(true);
|
|
174
|
+
expect(json.inviteRedemption).toBe('redeemed');
|
|
175
|
+
expect(json.memberId).toEqual(expect.any(String));
|
|
176
|
+
expect(json.denied).toBeUndefined();
|
|
177
|
+
|
|
178
|
+
// Verify the user is now an active member
|
|
179
|
+
const member = findMember({
|
|
180
|
+
assistantId: 'self',
|
|
181
|
+
sourceChannel: 'telegram',
|
|
182
|
+
externalUserId: 'user-invite-123',
|
|
183
|
+
});
|
|
184
|
+
expect(member).not.toBeNull();
|
|
185
|
+
expect(member!.status).toBe('active');
|
|
186
|
+
|
|
187
|
+
// Verify a welcome reply was delivered
|
|
188
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
189
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
190
|
+
expect(replyText).toContain("Welcome! You've been granted access via invite link.");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('non-member with invalid token gets refusal text', async () => {
|
|
194
|
+
const req = buildInviteRequest('completely-bogus-token-xyz');
|
|
195
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
196
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
197
|
+
|
|
198
|
+
expect(json.accepted).toBe(true);
|
|
199
|
+
expect(json.denied).toBe(true);
|
|
200
|
+
expect(json.inviteRedemption).toBe('invalid_token');
|
|
201
|
+
|
|
202
|
+
// Verify refusal reply was delivered
|
|
203
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
204
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
205
|
+
expect(replyText).toContain('no longer valid');
|
|
206
|
+
|
|
207
|
+
// Verify the user was NOT made a member
|
|
208
|
+
const member = findMember({
|
|
209
|
+
assistantId: 'self',
|
|
210
|
+
sourceChannel: 'telegram',
|
|
211
|
+
externalUserId: 'user-invite-123',
|
|
212
|
+
});
|
|
213
|
+
expect(member).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('non-member with expired token gets appropriate message', async () => {
|
|
217
|
+
const { rawToken } = createInvite({
|
|
218
|
+
sourceChannel: 'telegram',
|
|
219
|
+
maxUses: 1,
|
|
220
|
+
expiresInMs: -1, // already expired
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const req = buildInviteRequest(rawToken);
|
|
224
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
225
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
226
|
+
|
|
227
|
+
expect(json.accepted).toBe(true);
|
|
228
|
+
expect(json.denied).toBe(true);
|
|
229
|
+
expect(json.inviteRedemption).toBe('expired');
|
|
230
|
+
|
|
231
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
232
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
233
|
+
expect(replyText).toContain('no longer valid');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test('non-member with revoked token gets refusal text', async () => {
|
|
237
|
+
const { rawToken, invite } = createInvite({
|
|
238
|
+
sourceChannel: 'telegram',
|
|
239
|
+
maxUses: 5,
|
|
240
|
+
});
|
|
241
|
+
revokeInvite(invite.id);
|
|
242
|
+
|
|
243
|
+
const req = buildInviteRequest(rawToken);
|
|
244
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
245
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
246
|
+
|
|
247
|
+
expect(json.accepted).toBe(true);
|
|
248
|
+
expect(json.denied).toBe(true);
|
|
249
|
+
expect(json.inviteRedemption).toBe('revoked');
|
|
250
|
+
|
|
251
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
252
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
253
|
+
expect(replyText).toContain('no longer valid');
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('existing /start gv_<token> guardian bootstrap flow is unaffected', async () => {
|
|
257
|
+
// Send a /start gv_ command — should not be intercepted by the invite flow.
|
|
258
|
+
// Without a valid bootstrap session, it should be denied at the ACL gate.
|
|
259
|
+
const req = buildInboundRequest({
|
|
260
|
+
content: '/start gv_some_bootstrap_token',
|
|
261
|
+
sourceMetadata: {
|
|
262
|
+
commandIntent: { type: 'start', payload: 'gv_some_bootstrap_token' },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
266
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
267
|
+
|
|
268
|
+
// Should be denied as a non-member (bootstrap token is invalid/no session)
|
|
269
|
+
expect(json.denied).toBe(true);
|
|
270
|
+
expect(json.reason).toBe('not_a_member');
|
|
271
|
+
// Should NOT have invite redemption fields
|
|
272
|
+
expect(json.inviteRedemption).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('duplicate Telegram webhook deliveries do not double-redeem', async () => {
|
|
276
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
277
|
+
|
|
278
|
+
const sharedMessageId = `msg-dedup-${Date.now()}`;
|
|
279
|
+
const makeReq = () => buildInviteRequest(rawToken, {
|
|
280
|
+
externalMessageId: sharedMessageId,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// First delivery
|
|
284
|
+
const resp1 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
|
|
285
|
+
const json1 = await resp1.json() as Record<string, unknown>;
|
|
286
|
+
expect(json1.inviteRedemption).toBe('redeemed');
|
|
287
|
+
|
|
288
|
+
// Second delivery (duplicate webhook)
|
|
289
|
+
const resp2 = await handleChannelInbound(makeReq(), undefined, TEST_BEARER_TOKEN);
|
|
290
|
+
const json2 = await resp2.json() as Record<string, unknown>;
|
|
291
|
+
// Dedup kicks in — the message is treated as a duplicate and no second
|
|
292
|
+
// redemption attempt occurs.
|
|
293
|
+
expect(json2.duplicate).toBe(true);
|
|
294
|
+
|
|
295
|
+
// Only one welcome reply was delivered
|
|
296
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('existing active member sending normal message is unaffected', async () => {
|
|
300
|
+
// Pre-create an active member
|
|
301
|
+
upsertMember({
|
|
302
|
+
assistantId: 'self',
|
|
303
|
+
sourceChannel: 'telegram',
|
|
304
|
+
externalUserId: 'user-active-member',
|
|
305
|
+
externalChatId: 'chat-active',
|
|
306
|
+
status: 'active',
|
|
307
|
+
policy: 'allow',
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
// Active member sends a normal message (no invite token)
|
|
311
|
+
const req = buildInboundRequest({
|
|
312
|
+
content: 'Hello, just a normal message!',
|
|
313
|
+
senderExternalUserId: 'user-active-member',
|
|
314
|
+
externalChatId: 'chat-active',
|
|
315
|
+
sourceMetadata: {},
|
|
316
|
+
});
|
|
317
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
318
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
319
|
+
|
|
320
|
+
// Should be accepted normally, not denied, not invite-redeemed
|
|
321
|
+
expect(json.accepted).toBe(true);
|
|
322
|
+
expect(json.denied).toBeUndefined();
|
|
323
|
+
expect(json.inviteRedemption).toBeUndefined();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test('channel mismatch returns appropriate message', async () => {
|
|
327
|
+
// Create an invite for SMS, but try to redeem via Telegram
|
|
328
|
+
const { rawToken } = createInvite({ sourceChannel: 'sms', maxUses: 5 });
|
|
329
|
+
|
|
330
|
+
const req = buildInviteRequest(rawToken);
|
|
331
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
332
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
333
|
+
|
|
334
|
+
expect(json.accepted).toBe(true);
|
|
335
|
+
expect(json.denied).toBe(true);
|
|
336
|
+
expect(json.inviteRedemption).toBe('channel_mismatch');
|
|
337
|
+
|
|
338
|
+
expect(deliverReplyCalls.length).toBe(1);
|
|
339
|
+
const replyText = (deliverReplyCalls[0].payload as Record<string, unknown>).text;
|
|
340
|
+
expect(replyText).toContain('not valid for this channel');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test('already-active member with invite token gets acknowledgement', async () => {
|
|
344
|
+
const { rawToken } = createInvite({ sourceChannel: 'telegram', maxUses: 5 });
|
|
345
|
+
|
|
346
|
+
// Pre-create an active member that will click the invite link
|
|
347
|
+
upsertMember({
|
|
348
|
+
assistantId: 'self',
|
|
349
|
+
sourceChannel: 'telegram',
|
|
350
|
+
externalUserId: 'user-already-active',
|
|
351
|
+
externalChatId: 'chat-invite-test',
|
|
352
|
+
status: 'active',
|
|
353
|
+
policy: 'allow',
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
const req = buildInviteRequest(rawToken, {
|
|
357
|
+
senderExternalUserId: 'user-already-active',
|
|
358
|
+
});
|
|
359
|
+
const resp = await handleChannelInbound(req, undefined, TEST_BEARER_TOKEN);
|
|
360
|
+
const json = await resp.json() as Record<string, unknown>;
|
|
361
|
+
|
|
362
|
+
// Active members pass through the ACL gate, so the invite intercept
|
|
363
|
+
// does not fire. The message proceeds to normal processing.
|
|
364
|
+
expect(json.accepted).toBe(true);
|
|
365
|
+
expect(json.denied).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
});
|