@vellumai/assistant 0.3.2 → 0.3.3
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/README.md +82 -13
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
- package/src/__tests__/app-git-history.test.ts +22 -27
- package/src/__tests__/app-git-service.test.ts +44 -78
- package/src/__tests__/channel-approval-routes.test.ts +930 -14
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-delivery-store.test.ts +104 -1
- package/src/__tests__/channel-guardian.test.ts +184 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +665 -5
- package/src/__tests__/ingress-url-consistency.test.ts +64 -0
- package/src/__tests__/ipc-snapshot.test.ts +10 -0
- package/src/__tests__/run-orchestrator.test.ts +1 -1
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/schema.ts +3 -0
- package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
- package/src/daemon/handlers/config.ts +168 -15
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +61 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +10 -0
- package/src/daemon/session-agent-loop.ts +4 -0
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/inbound/public-ingress-urls.ts +20 -3
- package/src/index.ts +1 -23
- package/src/memory/app-git-service.ts +24 -0
- package/src/memory/app-store.ts +0 -21
- package/src/memory/channel-delivery-store.ts +74 -3
- package/src/memory/channel-guardian-store.ts +54 -26
- package/src/memory/conversation-key-store.ts +20 -0
- package/src/memory/conversation-store.ts +14 -2
- package/src/memory/db.ts +12 -0
- package/src/memory/schema.ts +5 -0
- package/src/runtime/http-server.ts +13 -5
- package/src/runtime/routes/channel-routes.ts +134 -43
- package/src/skills/clawhub.ts +6 -2
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/skills/vellum-catalog.ts +45 -2
- package/src/tools/subagent/spawn.ts +2 -0
|
@@ -65,8 +65,10 @@ function ensureConversation(conversationId: string): void {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function ensureMessage(messageId: string, conversationId: string): void {
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
69
|
const { getDb } = require('../memory/db.js');
|
|
69
70
|
const db = getDb();
|
|
71
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
70
72
|
const { messages } = require('../memory/schema.js');
|
|
71
73
|
try {
|
|
72
74
|
db.insert(messages).values({
|
|
@@ -24,7 +24,7 @@ mock.module('../util/logger.js', () => ({
|
|
|
24
24
|
}));
|
|
25
25
|
|
|
26
26
|
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
27
|
-
import { channelInboundEvents, messages } from '../memory/schema.js';
|
|
27
|
+
import { channelInboundEvents, conversations, messages } from '../memory/schema.js';
|
|
28
28
|
import {
|
|
29
29
|
recordInbound,
|
|
30
30
|
linkMessage,
|
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
} from '../memory/channel-delivery-store.js';
|
|
41
41
|
import { RETRY_MAX_ATTEMPTS } from '../memory/job-utils.js';
|
|
42
42
|
import { eq } from 'drizzle-orm';
|
|
43
|
+
import { setConversationKey, getConversationByKey } from '../memory/conversation-key-store.js';
|
|
44
|
+
import { handleDeleteConversation } from '../runtime/routes/channel-routes.js';
|
|
43
45
|
|
|
44
46
|
initializeDb();
|
|
45
47
|
|
|
@@ -135,6 +137,27 @@ describe('channel-delivery-store', () => {
|
|
|
135
137
|
expect(r1.conversationId).not.toBe(r2.conversationId);
|
|
136
138
|
});
|
|
137
139
|
|
|
140
|
+
test('same chat/channel but different assistantId uses different conversations', () => {
|
|
141
|
+
const r1 = recordInbound('telegram', 'chat-1', 'msg-1', { assistantId: 'asst-A' });
|
|
142
|
+
const r2 = recordInbound('telegram', 'chat-1', 'msg-2', { assistantId: 'asst-B' });
|
|
143
|
+
|
|
144
|
+
expect(r1.conversationId).not.toBe(r2.conversationId);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('self assistant reuses legacy key and creates scoped alias', () => {
|
|
148
|
+
// Create a conversation via the legacy (no assistantId) path
|
|
149
|
+
const legacy = recordInbound('telegram', 'chat-1', 'msg-1');
|
|
150
|
+
|
|
151
|
+
// Now use assistantId='self' — should reuse the legacy conversation
|
|
152
|
+
const scoped = recordInbound('telegram', 'chat-1', 'msg-2', { assistantId: 'self' });
|
|
153
|
+
expect(scoped.conversationId).toBe(legacy.conversationId);
|
|
154
|
+
|
|
155
|
+
// The scoped alias key should exist, so subsequent calls with 'self'
|
|
156
|
+
// resolve directly without falling back to the legacy key
|
|
157
|
+
const again = recordInbound('telegram', 'chat-1', 'msg-3', { assistantId: 'self' });
|
|
158
|
+
expect(again.conversationId).toBe(legacy.conversationId);
|
|
159
|
+
});
|
|
160
|
+
|
|
138
161
|
// ── Deduplication ─────────────────────────────────────────────────
|
|
139
162
|
|
|
140
163
|
test('duplicate inbound returns duplicate: true with same eventId', () => {
|
|
@@ -444,4 +467,84 @@ describe('channel-delivery-store', () => {
|
|
|
444
467
|
const found = findMessageBySourceId('telegram', 'chat-1', 'src-1');
|
|
445
468
|
expect(found!.messageId).toBe(msgId);
|
|
446
469
|
});
|
|
470
|
+
|
|
471
|
+
// ── handleDeleteConversation assistantId parameter ───────────────
|
|
472
|
+
|
|
473
|
+
test('handleDeleteConversation uses route assistantId param to delete scoped key', async () => {
|
|
474
|
+
// Set up a scoped conversation key like the one created by recordInbound
|
|
475
|
+
// with a specific assistantId.
|
|
476
|
+
const convId = 'conv-delete-test';
|
|
477
|
+
const scopedKey = 'asst:my-assistant:telegram:chat-del';
|
|
478
|
+
const legacyKey = 'telegram:chat-del';
|
|
479
|
+
|
|
480
|
+
// Insert a conversation row so FK constraints are satisfied
|
|
481
|
+
const now = Date.now();
|
|
482
|
+
const db = getDb();
|
|
483
|
+
db.insert(conversations).values({
|
|
484
|
+
id: convId,
|
|
485
|
+
title: 'test',
|
|
486
|
+
createdAt: now,
|
|
487
|
+
updatedAt: now,
|
|
488
|
+
}).run();
|
|
489
|
+
setConversationKey(scopedKey, convId);
|
|
490
|
+
setConversationKey(legacyKey, convId);
|
|
491
|
+
|
|
492
|
+
// Verify both keys exist
|
|
493
|
+
expect(getConversationByKey(scopedKey)).not.toBeNull();
|
|
494
|
+
expect(getConversationByKey(legacyKey)).not.toBeNull();
|
|
495
|
+
|
|
496
|
+
// Call handleDeleteConversation with assistantId as a parameter (not in body)
|
|
497
|
+
const req = new Request('http://localhost/channels/conversation', {
|
|
498
|
+
method: 'DELETE',
|
|
499
|
+
headers: { 'Content-Type': 'application/json' },
|
|
500
|
+
body: JSON.stringify({
|
|
501
|
+
sourceChannel: 'telegram',
|
|
502
|
+
externalChatId: 'chat-del',
|
|
503
|
+
// Note: no assistantId in the body — it comes from the route param
|
|
504
|
+
}),
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
const res = await handleDeleteConversation(req, 'my-assistant');
|
|
508
|
+
expect(res.status).toBe(200);
|
|
509
|
+
|
|
510
|
+
const json = await res.json() as { ok: boolean };
|
|
511
|
+
expect(json.ok).toBe(true);
|
|
512
|
+
|
|
513
|
+
// Both the legacy key and the scoped key should be deleted
|
|
514
|
+
expect(getConversationByKey(scopedKey)).toBeNull();
|
|
515
|
+
expect(getConversationByKey(legacyKey)).toBeNull();
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('handleDeleteConversation defaults to "self" when no assistantId provided', async () => {
|
|
519
|
+
const convId = 'conv-delete-default';
|
|
520
|
+
const scopedKey = 'asst:self:telegram:chat-def';
|
|
521
|
+
const legacyKey = 'telegram:chat-def';
|
|
522
|
+
|
|
523
|
+
const now = Date.now();
|
|
524
|
+
const db = getDb();
|
|
525
|
+
db.insert(conversations).values({
|
|
526
|
+
id: convId,
|
|
527
|
+
title: 'test',
|
|
528
|
+
createdAt: now,
|
|
529
|
+
updatedAt: now,
|
|
530
|
+
}).run();
|
|
531
|
+
setConversationKey(scopedKey, convId);
|
|
532
|
+
setConversationKey(legacyKey, convId);
|
|
533
|
+
|
|
534
|
+
const req = new Request('http://localhost/channels/conversation', {
|
|
535
|
+
method: 'DELETE',
|
|
536
|
+
headers: { 'Content-Type': 'application/json' },
|
|
537
|
+
body: JSON.stringify({
|
|
538
|
+
sourceChannel: 'telegram',
|
|
539
|
+
externalChatId: 'chat-def',
|
|
540
|
+
}),
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// No assistantId parameter — should default to 'self'
|
|
544
|
+
const res = await handleDeleteConversation(req);
|
|
545
|
+
expect(res.status).toBe(200);
|
|
546
|
+
|
|
547
|
+
expect(getConversationByKey(scopedKey)).toBeNull();
|
|
548
|
+
expect(getConversationByKey(legacyKey)).toBeNull();
|
|
549
|
+
});
|
|
447
550
|
});
|
|
@@ -949,7 +949,7 @@ describe('guardian service rate limiting', () => {
|
|
|
949
949
|
|
|
950
950
|
test('valid challenge still succeeds when under threshold', () => {
|
|
951
951
|
// Record a couple invalid attempts
|
|
952
|
-
const { secret } = createVerificationChallenge('asst-1', 'telegram');
|
|
952
|
+
const { secret: _secret } = createVerificationChallenge('asst-1', 'telegram');
|
|
953
953
|
validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-1', 'user-42', 'chat-42');
|
|
954
954
|
validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-2', 'user-42', 'chat-42');
|
|
955
955
|
|
|
@@ -1003,3 +1003,186 @@ describe('guardian service rate limiting', () => {
|
|
|
1003
1003
|
expect(result.success).toBe(true);
|
|
1004
1004
|
});
|
|
1005
1005
|
});
|
|
1006
|
+
|
|
1007
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1008
|
+
// 8. Assistant-scoped guardian resolution
|
|
1009
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1010
|
+
|
|
1011
|
+
describe('assistant-scoped guardian resolution', () => {
|
|
1012
|
+
beforeEach(() => {
|
|
1013
|
+
resetTables();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
test('isGuardian resolves independently per assistantId', () => {
|
|
1017
|
+
// Create guardian binding for asst-A on telegram
|
|
1018
|
+
createBinding({
|
|
1019
|
+
assistantId: 'asst-A',
|
|
1020
|
+
channel: 'telegram',
|
|
1021
|
+
guardianExternalUserId: 'user-alpha',
|
|
1022
|
+
guardianDeliveryChatId: 'chat-alpha',
|
|
1023
|
+
});
|
|
1024
|
+
// Create guardian binding for asst-B on telegram with a different user
|
|
1025
|
+
createBinding({
|
|
1026
|
+
assistantId: 'asst-B',
|
|
1027
|
+
channel: 'telegram',
|
|
1028
|
+
guardianExternalUserId: 'user-beta',
|
|
1029
|
+
guardianDeliveryChatId: 'chat-beta',
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
// user-alpha is guardian for asst-A but not asst-B
|
|
1033
|
+
expect(isGuardian('asst-A', 'telegram', 'user-alpha')).toBe(true);
|
|
1034
|
+
expect(isGuardian('asst-B', 'telegram', 'user-alpha')).toBe(false);
|
|
1035
|
+
|
|
1036
|
+
// user-beta is guardian for asst-B but not asst-A
|
|
1037
|
+
expect(isGuardian('asst-B', 'telegram', 'user-beta')).toBe(true);
|
|
1038
|
+
expect(isGuardian('asst-A', 'telegram', 'user-beta')).toBe(false);
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
test('getGuardianBinding returns different bindings for different assistants', () => {
|
|
1042
|
+
createBinding({
|
|
1043
|
+
assistantId: 'asst-A',
|
|
1044
|
+
channel: 'telegram',
|
|
1045
|
+
guardianExternalUserId: 'user-alpha',
|
|
1046
|
+
guardianDeliveryChatId: 'chat-alpha',
|
|
1047
|
+
});
|
|
1048
|
+
createBinding({
|
|
1049
|
+
assistantId: 'asst-B',
|
|
1050
|
+
channel: 'telegram',
|
|
1051
|
+
guardianExternalUserId: 'user-beta',
|
|
1052
|
+
guardianDeliveryChatId: 'chat-beta',
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
const bindingA = getGuardianBinding('asst-A', 'telegram');
|
|
1056
|
+
const bindingB = getGuardianBinding('asst-B', 'telegram');
|
|
1057
|
+
|
|
1058
|
+
expect(bindingA).not.toBeNull();
|
|
1059
|
+
expect(bindingB).not.toBeNull();
|
|
1060
|
+
expect(bindingA!.guardianExternalUserId).toBe('user-alpha');
|
|
1061
|
+
expect(bindingB!.guardianExternalUserId).toBe('user-beta');
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test('revoking binding for one assistant does not affect another', () => {
|
|
1065
|
+
createBinding({
|
|
1066
|
+
assistantId: 'asst-A',
|
|
1067
|
+
channel: 'telegram',
|
|
1068
|
+
guardianExternalUserId: 'user-alpha',
|
|
1069
|
+
guardianDeliveryChatId: 'chat-alpha',
|
|
1070
|
+
});
|
|
1071
|
+
createBinding({
|
|
1072
|
+
assistantId: 'asst-B',
|
|
1073
|
+
channel: 'telegram',
|
|
1074
|
+
guardianExternalUserId: 'user-beta',
|
|
1075
|
+
guardianDeliveryChatId: 'chat-beta',
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
serviceRevokeBinding('asst-A', 'telegram');
|
|
1079
|
+
|
|
1080
|
+
expect(getGuardianBinding('asst-A', 'telegram')).toBeNull();
|
|
1081
|
+
expect(getGuardianBinding('asst-B', 'telegram')).not.toBeNull();
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
test('validateAndConsumeChallenge scoped to assistantId', () => {
|
|
1085
|
+
// Create challenge for asst-A
|
|
1086
|
+
const { secret: secretA } = createVerificationChallenge('asst-A', 'telegram');
|
|
1087
|
+
// Create challenge for asst-B
|
|
1088
|
+
const { secret: secretB } = createVerificationChallenge('asst-B', 'telegram');
|
|
1089
|
+
|
|
1090
|
+
// Attempting to consume asst-A challenge with asst-B should fail
|
|
1091
|
+
const crossResult = validateAndConsumeChallenge(
|
|
1092
|
+
'asst-B', 'telegram', secretA, 'user-1', 'chat-1',
|
|
1093
|
+
);
|
|
1094
|
+
expect(crossResult.success).toBe(false);
|
|
1095
|
+
|
|
1096
|
+
// Consuming with correct assistantId should succeed
|
|
1097
|
+
const resultA = validateAndConsumeChallenge(
|
|
1098
|
+
'asst-A', 'telegram', secretA, 'user-1', 'chat-1',
|
|
1099
|
+
);
|
|
1100
|
+
expect(resultA.success).toBe(true);
|
|
1101
|
+
|
|
1102
|
+
const resultB = validateAndConsumeChallenge(
|
|
1103
|
+
'asst-B', 'telegram', secretB, 'user-2', 'chat-2',
|
|
1104
|
+
);
|
|
1105
|
+
expect(resultB.success).toBe(true);
|
|
1106
|
+
|
|
1107
|
+
// Verify bindings are scoped correctly
|
|
1108
|
+
const bindingA = getGuardianBinding('asst-A', 'telegram');
|
|
1109
|
+
const bindingB = getGuardianBinding('asst-B', 'telegram');
|
|
1110
|
+
expect(bindingA!.guardianExternalUserId).toBe('user-1');
|
|
1111
|
+
expect(bindingB!.guardianExternalUserId).toBe('user-2');
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1116
|
+
// 9. Assistant-scoped approval request lookups
|
|
1117
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1118
|
+
|
|
1119
|
+
describe('assistant-scoped approval request lookups', () => {
|
|
1120
|
+
beforeEach(() => {
|
|
1121
|
+
resetTables();
|
|
1122
|
+
});
|
|
1123
|
+
|
|
1124
|
+
test('createApprovalRequest stores assistantId and defaults to self', () => {
|
|
1125
|
+
const reqWithoutId = createApprovalRequest({
|
|
1126
|
+
runId: 'run-1',
|
|
1127
|
+
conversationId: 'conv-1',
|
|
1128
|
+
channel: 'telegram',
|
|
1129
|
+
requesterExternalUserId: 'user-99',
|
|
1130
|
+
requesterChatId: 'chat-99',
|
|
1131
|
+
guardianExternalUserId: 'user-42',
|
|
1132
|
+
guardianChatId: 'chat-42',
|
|
1133
|
+
toolName: 'shell',
|
|
1134
|
+
expiresAt: Date.now() + 300_000,
|
|
1135
|
+
});
|
|
1136
|
+
expect(reqWithoutId.assistantId).toBe('self');
|
|
1137
|
+
|
|
1138
|
+
const reqWithId = createApprovalRequest({
|
|
1139
|
+
runId: 'run-2',
|
|
1140
|
+
conversationId: 'conv-2',
|
|
1141
|
+
assistantId: 'asst-A',
|
|
1142
|
+
channel: 'telegram',
|
|
1143
|
+
requesterExternalUserId: 'user-99',
|
|
1144
|
+
requesterChatId: 'chat-99',
|
|
1145
|
+
guardianExternalUserId: 'user-42',
|
|
1146
|
+
guardianChatId: 'chat-42',
|
|
1147
|
+
toolName: 'browser',
|
|
1148
|
+
expiresAt: Date.now() + 300_000,
|
|
1149
|
+
});
|
|
1150
|
+
expect(reqWithId.assistantId).toBe('asst-A');
|
|
1151
|
+
});
|
|
1152
|
+
|
|
1153
|
+
test('approval requests from different assistants are independent', () => {
|
|
1154
|
+
createApprovalRequest({
|
|
1155
|
+
runId: 'run-A',
|
|
1156
|
+
conversationId: 'conv-A',
|
|
1157
|
+
assistantId: 'asst-A',
|
|
1158
|
+
channel: 'telegram',
|
|
1159
|
+
requesterExternalUserId: 'user-99',
|
|
1160
|
+
requesterChatId: 'chat-99',
|
|
1161
|
+
guardianExternalUserId: 'user-42',
|
|
1162
|
+
guardianChatId: 'chat-42',
|
|
1163
|
+
toolName: 'shell',
|
|
1164
|
+
expiresAt: Date.now() + 300_000,
|
|
1165
|
+
});
|
|
1166
|
+
createApprovalRequest({
|
|
1167
|
+
runId: 'run-B',
|
|
1168
|
+
conversationId: 'conv-B',
|
|
1169
|
+
assistantId: 'asst-B',
|
|
1170
|
+
channel: 'telegram',
|
|
1171
|
+
requesterExternalUserId: 'user-88',
|
|
1172
|
+
requesterChatId: 'chat-88',
|
|
1173
|
+
guardianExternalUserId: 'user-42',
|
|
1174
|
+
guardianChatId: 'chat-42',
|
|
1175
|
+
toolName: 'browser',
|
|
1176
|
+
expiresAt: Date.now() + 300_000,
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
const foundA = getPendingApprovalForRun('run-A');
|
|
1180
|
+
const foundB = getPendingApprovalForRun('run-B');
|
|
1181
|
+
expect(foundA).not.toBeNull();
|
|
1182
|
+
expect(foundB).not.toBeNull();
|
|
1183
|
+
expect(foundA!.assistantId).toBe('asst-A');
|
|
1184
|
+
expect(foundB!.assistantId).toBe('asst-B');
|
|
1185
|
+
expect(foundA!.toolName).toBe('shell');
|
|
1186
|
+
expect(foundB!.toolName).toBe('browser');
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
@@ -189,6 +189,7 @@ describe('Invariant 2: no generic plaintext secret read API', () => {
|
|
|
189
189
|
'calls/elevenlabs-config.ts', // ElevenLabs voice quality API key lookup
|
|
190
190
|
'calls/twilio-config.ts', // call infrastructure credential lookup
|
|
191
191
|
'calls/twilio-provider.ts', // call infrastructure credential lookup
|
|
192
|
+
'calls/twilio-rest.ts', // Twilio REST API credential lookup
|
|
192
193
|
'cli/config-commands.ts', // CLI credential management commands
|
|
193
194
|
'runtime/http-server.ts', // HTTP server credential lookup
|
|
194
195
|
'daemon/handlers/twitter-auth.ts', // Twitter OAuth token storage
|
|
@@ -159,6 +159,10 @@ mock.module('../security/secret-allowlist.js', () => ({
|
|
|
159
159
|
resetAllowlist: () => {},
|
|
160
160
|
}));
|
|
161
161
|
|
|
162
|
+
mock.module('../memory/external-conversation-store.js', () => ({
|
|
163
|
+
getBindingsForConversations: () => new Map(),
|
|
164
|
+
}));
|
|
165
|
+
|
|
162
166
|
mock.module('../memory/conversation-store.js', () => ({
|
|
163
167
|
getLatestConversation: () => conversation,
|
|
164
168
|
createConversation: (titleOrOpts?: string | { title?: string; threadType?: string }) => {
|
|
@@ -181,6 +185,7 @@ mock.module('../memory/conversation-store.js', () => ({
|
|
|
181
185
|
},
|
|
182
186
|
getMessages: () => [],
|
|
183
187
|
listConversations: () => [conversation],
|
|
188
|
+
countConversations: () => 1,
|
|
184
189
|
}));
|
|
185
190
|
|
|
186
191
|
mock.module('../daemon/session.js', () => ({
|
|
@@ -113,14 +113,11 @@ mock.module('../security/secure-keys.js', () => ({
|
|
|
113
113
|
deleteSecureKey: () => {},
|
|
114
114
|
}));
|
|
115
115
|
|
|
116
|
-
mock
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
getTwilioConnectActionUrl: () => 'https://test.example.com/webhooks/twilio/connect-action',
|
|
122
|
-
getOAuthCallbackUrl: () => 'https://test.example.com/webhooks/oauth/callback',
|
|
123
|
-
}));
|
|
116
|
+
// NOTE: Do NOT mock '../inbound/public-ingress-urls.js' here.
|
|
117
|
+
// Those are pure functions that derive URLs from the config object returned by
|
|
118
|
+
// loadConfig() (which is already mocked above). Mocking them at the module level
|
|
119
|
+
// leaks into other test files (e.g. ingress-url-consistency.test.ts) that need
|
|
120
|
+
// the real implementations, causing cross-test contamination.
|
|
124
121
|
|
|
125
122
|
// Mock the oauth callback registry
|
|
126
123
|
mock.module('../security/oauth-callback-registry.js', () => ({
|
|
@@ -289,6 +286,49 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
289
286
|
});
|
|
290
287
|
});
|
|
291
288
|
|
|
289
|
+
// ── SMS-specific direct webhook routes blocked ──────────────────────
|
|
290
|
+
|
|
291
|
+
describe('SMS webhook routes are blocked at the runtime (gateway-only)', () => {
|
|
292
|
+
|
|
293
|
+
test('POST /webhooks/twilio/sms returns 410 (cannot bypass gateway)', async () => {
|
|
294
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/sms`, {
|
|
295
|
+
method: 'POST',
|
|
296
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
297
|
+
body: makeFormBody({ Body: 'hello', From: '+15551234567', To: '+15559876543', MessageSid: 'SM123' }),
|
|
298
|
+
});
|
|
299
|
+
expect(res.status).toBe(410);
|
|
300
|
+
const body = await res.json() as { error: string; code: string };
|
|
301
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
302
|
+
expect(body.error).toContain('Direct webhook access disabled');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)', async () => {
|
|
306
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/sms`, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
309
|
+
body: makeFormBody({ Body: 'hello', From: '+15551234567', MessageSid: 'SM456' }),
|
|
310
|
+
});
|
|
311
|
+
expect(res.status).toBe(410);
|
|
312
|
+
const body = await res.json() as { error: string; code: string };
|
|
313
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)', async () => {
|
|
317
|
+
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/sms`, {
|
|
318
|
+
method: 'POST',
|
|
319
|
+
headers: {
|
|
320
|
+
...AUTH_HEADERS,
|
|
321
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
322
|
+
},
|
|
323
|
+
body: makeFormBody({ Body: 'sneaky', From: '+15551234567', MessageSid: 'SM789' }),
|
|
324
|
+
});
|
|
325
|
+
// The gateway-only guard runs before auth for Twilio webhook paths
|
|
326
|
+
expect(res.status).toBe(410);
|
|
327
|
+
const body = await res.json() as { error: string; code: string };
|
|
328
|
+
expect(body.code).toBe('GATEWAY_ONLY');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
292
332
|
// ── Internal forwarding routes still work ─────
|
|
293
333
|
|
|
294
334
|
describe('internal forwarding routes are not blocked', () => {
|
|
@@ -601,6 +641,45 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
601
641
|
// header, the request is rejected if bearer auth is missing.
|
|
602
642
|
expect(res.status).toBe(401);
|
|
603
643
|
});
|
|
644
|
+
|
|
645
|
+
test('POST /v1/channels/inbound with SMS sourceChannel requires X-Gateway-Origin', async () => {
|
|
646
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
647
|
+
method: 'POST',
|
|
648
|
+
headers: {
|
|
649
|
+
...AUTH_HEADERS,
|
|
650
|
+
'Content-Type': 'application/json',
|
|
651
|
+
},
|
|
652
|
+
body: JSON.stringify({
|
|
653
|
+
sourceChannel: 'sms',
|
|
654
|
+
externalChatId: '+15551234567',
|
|
655
|
+
externalMessageId: 'SM-test-gw-1',
|
|
656
|
+
content: 'hello via SMS',
|
|
657
|
+
}),
|
|
658
|
+
});
|
|
659
|
+
// SMS messages must also go through the gateway — missing X-Gateway-Origin is rejected.
|
|
660
|
+
expect(res.status).toBe(403);
|
|
661
|
+
const body = await res.json() as { error: string; code: string };
|
|
662
|
+
expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
test('POST /v1/channels/inbound with SMS sourceChannel and valid X-Gateway-Origin passes', async () => {
|
|
666
|
+
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: {
|
|
669
|
+
...AUTH_HEADERS,
|
|
670
|
+
'Content-Type': 'application/json',
|
|
671
|
+
'X-Gateway-Origin': TEST_TOKEN,
|
|
672
|
+
},
|
|
673
|
+
body: JSON.stringify({
|
|
674
|
+
sourceChannel: 'sms',
|
|
675
|
+
externalChatId: '+15551234567',
|
|
676
|
+
externalMessageId: 'SM-test-gw-2',
|
|
677
|
+
content: 'hello via SMS',
|
|
678
|
+
}),
|
|
679
|
+
});
|
|
680
|
+
// Should NOT be 403 — the gateway-origin check passes.
|
|
681
|
+
expect(res.status).not.toBe(403);
|
|
682
|
+
});
|
|
604
683
|
});
|
|
605
684
|
|
|
606
685
|
// ── Startup warning for non-loopback host ──────────────────────────
|
|
@@ -905,6 +905,7 @@ describe('Telegram config handler', () => {
|
|
|
905
905
|
|
|
906
906
|
import { handleGuardianVerification } from '../daemon/handlers/config.js';
|
|
907
907
|
import type { GuardianVerificationRequest } from '../daemon/ipc-contract.js';
|
|
908
|
+
import { createBinding } from '../memory/channel-guardian-store.js';
|
|
908
909
|
|
|
909
910
|
describe('Guardian verification IPC actions', () => {
|
|
910
911
|
beforeEach(() => {
|
|
@@ -965,4 +966,85 @@ describe('Guardian verification IPC actions', () => {
|
|
|
965
966
|
expect(res.success).toBe(false);
|
|
966
967
|
expect(res.error).toContain('Unknown action');
|
|
967
968
|
});
|
|
969
|
+
|
|
970
|
+
test('create_challenge with explicit assistantId scopes challenge to that assistant', () => {
|
|
971
|
+
const msg: GuardianVerificationRequest = {
|
|
972
|
+
type: 'guardian_verification',
|
|
973
|
+
action: 'create_challenge',
|
|
974
|
+
channel: 'telegram',
|
|
975
|
+
assistantId: 'asst-ipc-X',
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const { ctx, sent } = createTestContext();
|
|
979
|
+
handleGuardianVerification(msg, {} as net.Socket, ctx);
|
|
980
|
+
|
|
981
|
+
expect(sent).toHaveLength(1);
|
|
982
|
+
const res = sent[0] as { type: string; success: boolean; secret?: string; instruction?: string };
|
|
983
|
+
expect(res.success).toBe(true);
|
|
984
|
+
expect(res.secret).toBeDefined();
|
|
985
|
+
expect(res.instruction).toContain('/guardian_verify');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
test('status action with explicit assistantId checks binding for that assistant', () => {
|
|
989
|
+
// Create a control binding for a known assistant so we can verify
|
|
990
|
+
// that querying a *different* assistantId actually returns bound=false
|
|
991
|
+
// (not just because no bindings exist at all).
|
|
992
|
+
createBinding({
|
|
993
|
+
assistantId: 'asst-ipc-bound',
|
|
994
|
+
channel: 'telegram',
|
|
995
|
+
guardianExternalUserId: 'guardian-user-1',
|
|
996
|
+
guardianDeliveryChatId: 'guardian-chat-1',
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
// Querying a different assistant should return bound=false
|
|
1000
|
+
const unboundMsg: GuardianVerificationRequest = {
|
|
1001
|
+
type: 'guardian_verification',
|
|
1002
|
+
action: 'status',
|
|
1003
|
+
channel: 'telegram',
|
|
1004
|
+
assistantId: 'asst-ipc-Y',
|
|
1005
|
+
};
|
|
1006
|
+
|
|
1007
|
+
const { ctx: ctx1, sent: sent1 } = createTestContext();
|
|
1008
|
+
handleGuardianVerification(unboundMsg, {} as net.Socket, ctx1);
|
|
1009
|
+
|
|
1010
|
+
expect(sent1).toHaveLength(1);
|
|
1011
|
+
const unboundRes = sent1[0] as { type: string; success: boolean; bound: boolean };
|
|
1012
|
+
expect(unboundRes.success).toBe(true);
|
|
1013
|
+
expect(unboundRes.bound).toBe(false);
|
|
1014
|
+
|
|
1015
|
+
// Querying the bound assistant should return bound=true
|
|
1016
|
+
const boundMsg: GuardianVerificationRequest = {
|
|
1017
|
+
type: 'guardian_verification',
|
|
1018
|
+
action: 'status',
|
|
1019
|
+
channel: 'telegram',
|
|
1020
|
+
assistantId: 'asst-ipc-bound',
|
|
1021
|
+
};
|
|
1022
|
+
|
|
1023
|
+
const { ctx: ctx2, sent: sent2 } = createTestContext();
|
|
1024
|
+
handleGuardianVerification(boundMsg, {} as net.Socket, ctx2);
|
|
1025
|
+
|
|
1026
|
+
expect(sent2).toHaveLength(1);
|
|
1027
|
+
const boundRes = sent2[0] as { type: string; success: boolean; bound: boolean; guardianExternalUserId?: string };
|
|
1028
|
+
expect(boundRes.success).toBe(true);
|
|
1029
|
+
expect(boundRes.bound).toBe(true);
|
|
1030
|
+
expect(boundRes.guardianExternalUserId).toBe('guardian-user-1');
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test('assistantId defaults to "self" when not provided', () => {
|
|
1034
|
+
// create_challenge without assistantId should scope to 'self'
|
|
1035
|
+
const createMsg: GuardianVerificationRequest = {
|
|
1036
|
+
type: 'guardian_verification',
|
|
1037
|
+
action: 'create_challenge',
|
|
1038
|
+
channel: 'telegram',
|
|
1039
|
+
// assistantId intentionally omitted
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const { ctx: ctx1, sent: sent1 } = createTestContext();
|
|
1043
|
+
handleGuardianVerification(createMsg, {} as net.Socket, ctx1);
|
|
1044
|
+
|
|
1045
|
+
expect(sent1).toHaveLength(1);
|
|
1046
|
+
const createRes = sent1[0] as { type: string; success: boolean; secret?: string };
|
|
1047
|
+
expect(createRes.success).toBe(true);
|
|
1048
|
+
expect(createRes.secret).toBeDefined();
|
|
1049
|
+
});
|
|
968
1050
|
});
|