@vellumai/assistant 0.3.2 → 0.3.4
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 -21
- 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__/call-orchestrator.test.ts +321 -0
- package/src/__tests__/channel-approval-routes.test.ts +1267 -93
- package/src/__tests__/channel-approval.test.ts +2 -0
- package/src/__tests__/channel-approvals.test.ts +51 -2
- package/src/__tests__/channel-delivery-store.test.ts +130 -1
- package/src/__tests__/channel-guardian.test.ts +371 -1
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/credential-security-invariants.test.ts +1 -0
- package/src/__tests__/daemon-lifecycle.test.ts +635 -0
- package/src/__tests__/daemon-server-session-init.test.ts +5 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
- package/src/__tests__/handlers-telegram-config.test.ts +82 -0
- package/src/__tests__/handlers-twilio-config.test.ts +738 -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__/secret-scanner.test.ts +223 -0
- package/src/__tests__/session-process-bridge.test.ts +2 -0
- package/src/__tests__/shell-parser-property.test.ts +357 -2
- package/src/__tests__/system-prompt.test.ts +25 -1
- package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
- package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
- package/src/__tests__/user-reference.test.ts +68 -0
- package/src/calls/call-orchestrator.ts +63 -11
- package/src/calls/twilio-config.ts +10 -1
- package/src/calls/twilio-rest.ts +70 -0
- package/src/cli/map.ts +6 -0
- package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
- package/src/commands/cc-command-registry.ts +14 -1
- package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
- package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
- package/src/config/bundled-skills/messaging/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/SKILL.md +4 -0
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +6 -3
- package/src/config/skills.ts +5 -32
- package/src/config/system-prompt.ts +16 -0
- package/src/config/user-reference.ts +29 -0
- package/src/config/vellum-skills/catalog.json +52 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
- package/src/daemon/auth-manager.ts +103 -0
- package/src/daemon/computer-use-session.ts +8 -1
- package/src/daemon/config-watcher.ts +253 -0
- package/src/daemon/handlers/config.ts +193 -17
- package/src/daemon/handlers/sessions.ts +5 -3
- package/src/daemon/handlers/skills.ts +60 -17
- package/src/daemon/ipc-contract-inventory.json +4 -0
- package/src/daemon/ipc-contract.ts +16 -0
- package/src/daemon/ipc-handler.ts +87 -0
- package/src/daemon/lifecycle.ts +16 -4
- package/src/daemon/ride-shotgun-handler.ts +11 -1
- package/src/daemon/server.ts +105 -502
- package/src/daemon/session-agent-loop.ts +9 -14
- package/src/daemon/session-process.ts +20 -3
- package/src/daemon/session-runtime-assembly.ts +60 -44
- package/src/daemon/session-slash.ts +50 -2
- package/src/daemon/session-surfaces.ts +17 -1
- package/src/daemon/session.ts +8 -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-connection.ts +28 -0
- package/src/memory/db-init.ts +1019 -0
- package/src/memory/db.ts +2 -1995
- package/src/memory/embedding-backend.ts +79 -11
- package/src/memory/indexer.ts +2 -0
- package/src/memory/job-utils.ts +64 -4
- package/src/memory/jobs-worker.ts +7 -1
- package/src/memory/recall-cache.ts +107 -0
- package/src/memory/retriever.ts +30 -1
- package/src/memory/schema-migration.ts +984 -0
- package/src/memory/schema.ts +6 -0
- package/src/memory/search/types.ts +2 -0
- package/src/permissions/prompter.ts +14 -3
- package/src/permissions/trust-store.ts +7 -0
- package/src/runtime/channel-approvals.ts +17 -3
- package/src/runtime/gateway-client.ts +2 -1
- package/src/runtime/http-server.ts +28 -9
- package/src/runtime/routes/channel-routes.ts +279 -100
- package/src/runtime/routes/run-routes.ts +7 -1
- package/src/runtime/run-orchestrator.ts +8 -1
- package/src/security/secret-scanner.ts +218 -0
- package/src/skills/clawhub.ts +6 -2
- package/src/skills/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -0
- package/src/subagent/manager.ts +4 -1
- package/src/subagent/types.ts +2 -0
- package/src/tools/browser/auto-navigate.ts +132 -24
- package/src/tools/browser/browser-manager.ts +67 -61
- package/src/tools/claude-code/claude-code.ts +55 -3
- package/src/tools/executor.ts +10 -2
- package/src/tools/skills/vellum-catalog.ts +75 -127
- package/src/tools/subagent/spawn.ts +2 -0
- package/src/tools/terminal/parser.ts +21 -5
- package/src/util/platform.ts +8 -1
- package/src/util/retry.ts +4 -4
|
@@ -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({
|
|
@@ -298,6 +298,53 @@ describe('handleChannelDecision', () => {
|
|
|
298
298
|
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
test('uses decision.runId to target the matching pending run', () => {
|
|
302
|
+
ensureConversation('conv-1');
|
|
303
|
+
const olderRun = createRun('conv-1');
|
|
304
|
+
setRunConfirmation(olderRun.id, {
|
|
305
|
+
...sampleConfirmation,
|
|
306
|
+
toolName: 'shell',
|
|
307
|
+
toolUseId: 'req-older',
|
|
308
|
+
});
|
|
309
|
+
const newerRun = createRun('conv-1');
|
|
310
|
+
setRunConfirmation(newerRun.id, {
|
|
311
|
+
...sampleConfirmation,
|
|
312
|
+
toolName: 'browser',
|
|
313
|
+
toolUseId: 'req-newer',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const orchestrator = makeMockOrchestrator();
|
|
317
|
+
const decision: ApprovalDecisionResult = {
|
|
318
|
+
action: 'approve_once',
|
|
319
|
+
source: 'telegram_button',
|
|
320
|
+
runId: newerRun.id,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const result = handleChannelDecision('conv-1', decision, orchestrator);
|
|
324
|
+
expect(result.applied).toBe(true);
|
|
325
|
+
expect(result.runId).toBe(newerRun.id);
|
|
326
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(newerRun.id, 'allow');
|
|
327
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalledWith(olderRun.id, 'allow');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('returns applied: false when decision.runId does not match a pending run', () => {
|
|
331
|
+
ensureConversation('conv-1');
|
|
332
|
+
const run = createRun('conv-1');
|
|
333
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
334
|
+
|
|
335
|
+
const orchestrator = makeMockOrchestrator();
|
|
336
|
+
const decision: ApprovalDecisionResult = {
|
|
337
|
+
action: 'approve_once',
|
|
338
|
+
source: 'telegram_button',
|
|
339
|
+
runId: 'run-missing',
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const result = handleChannelDecision('conv-1', decision, orchestrator);
|
|
343
|
+
expect(result.applied).toBe(false);
|
|
344
|
+
expect(result.runId).toBeUndefined();
|
|
345
|
+
expect(orchestrator.submitDecision).not.toHaveBeenCalled();
|
|
346
|
+
});
|
|
347
|
+
|
|
301
348
|
test('approve_always adds a trust rule and submits "allow"', () => {
|
|
302
349
|
ensureConversation('conv-1');
|
|
303
350
|
const run = createRun('conv-1');
|
|
@@ -319,14 +366,16 @@ describe('handleChannelDecision', () => {
|
|
|
319
366
|
expect(result.applied).toBe(true);
|
|
320
367
|
expect(result.runId).toBe(run.id);
|
|
321
368
|
|
|
322
|
-
// Trust rule added with first allowlist and scope option
|
|
369
|
+
// Trust rule added with first allowlist and scope option.
|
|
370
|
+
// executionTarget is undefined for core tools like 'shell' — only
|
|
371
|
+
// skill-origin tools persist it (see channel-approvals.ts).
|
|
323
372
|
expect(addRuleSpy).toHaveBeenCalledWith(
|
|
324
373
|
'shell',
|
|
325
374
|
'rm -rf *',
|
|
326
375
|
'/tmp/project',
|
|
327
376
|
'allow',
|
|
328
377
|
100,
|
|
329
|
-
{ executionTarget:
|
|
378
|
+
{ executionTarget: undefined },
|
|
330
379
|
);
|
|
331
380
|
|
|
332
381
|
// The run is still approved with a simple "allow"
|
|
@@ -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, externalConversationBindings, 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,110 @@ 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 with non-self assistant deletes only 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
|
+
db.insert(externalConversationBindings).values({
|
|
492
|
+
conversationId: convId,
|
|
493
|
+
sourceChannel: 'telegram',
|
|
494
|
+
externalChatId: 'chat-del',
|
|
495
|
+
createdAt: now,
|
|
496
|
+
updatedAt: now,
|
|
497
|
+
}).run();
|
|
498
|
+
|
|
499
|
+
// Verify both keys exist
|
|
500
|
+
expect(getConversationByKey(scopedKey)).not.toBeNull();
|
|
501
|
+
expect(getConversationByKey(legacyKey)).not.toBeNull();
|
|
502
|
+
|
|
503
|
+
// Call handleDeleteConversation with assistantId as a parameter (not in body)
|
|
504
|
+
const req = new Request('http://localhost/channels/conversation', {
|
|
505
|
+
method: 'DELETE',
|
|
506
|
+
headers: { 'Content-Type': 'application/json' },
|
|
507
|
+
body: JSON.stringify({
|
|
508
|
+
sourceChannel: 'telegram',
|
|
509
|
+
externalChatId: 'chat-del',
|
|
510
|
+
// Note: no assistantId in the body — it comes from the route param
|
|
511
|
+
}),
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
const res = await handleDeleteConversation(req, 'my-assistant');
|
|
515
|
+
expect(res.status).toBe(200);
|
|
516
|
+
|
|
517
|
+
const json = await res.json() as { ok: boolean };
|
|
518
|
+
expect(json.ok).toBe(true);
|
|
519
|
+
|
|
520
|
+
// Non-self delete should only remove the scoped key and preserve legacy.
|
|
521
|
+
expect(getConversationByKey(scopedKey)).toBeNull();
|
|
522
|
+
expect(getConversationByKey(legacyKey)).not.toBeNull();
|
|
523
|
+
// Non-self delete should not mutate assistant-agnostic external bindings.
|
|
524
|
+
const remainingBinding = db.select()
|
|
525
|
+
.from(externalConversationBindings)
|
|
526
|
+
.where(eq(externalConversationBindings.conversationId, convId))
|
|
527
|
+
.get();
|
|
528
|
+
expect(remainingBinding).not.toBeNull();
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('handleDeleteConversation defaults to "self" when no assistantId provided', async () => {
|
|
532
|
+
const convId = 'conv-delete-default';
|
|
533
|
+
const scopedKey = 'asst:self:telegram:chat-def';
|
|
534
|
+
const legacyKey = 'telegram:chat-def';
|
|
535
|
+
|
|
536
|
+
const now = Date.now();
|
|
537
|
+
const db = getDb();
|
|
538
|
+
db.insert(conversations).values({
|
|
539
|
+
id: convId,
|
|
540
|
+
title: 'test',
|
|
541
|
+
createdAt: now,
|
|
542
|
+
updatedAt: now,
|
|
543
|
+
}).run();
|
|
544
|
+
setConversationKey(scopedKey, convId);
|
|
545
|
+
setConversationKey(legacyKey, convId);
|
|
546
|
+
db.insert(externalConversationBindings).values({
|
|
547
|
+
conversationId: convId,
|
|
548
|
+
sourceChannel: 'telegram',
|
|
549
|
+
externalChatId: 'chat-def',
|
|
550
|
+
createdAt: now,
|
|
551
|
+
updatedAt: now,
|
|
552
|
+
}).run();
|
|
553
|
+
|
|
554
|
+
const req = new Request('http://localhost/channels/conversation', {
|
|
555
|
+
method: 'DELETE',
|
|
556
|
+
headers: { 'Content-Type': 'application/json' },
|
|
557
|
+
body: JSON.stringify({
|
|
558
|
+
sourceChannel: 'telegram',
|
|
559
|
+
externalChatId: 'chat-def',
|
|
560
|
+
}),
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
// No assistantId parameter — should default to 'self'
|
|
564
|
+
const res = await handleDeleteConversation(req);
|
|
565
|
+
expect(res.status).toBe(200);
|
|
566
|
+
|
|
567
|
+
expect(getConversationByKey(scopedKey)).toBeNull();
|
|
568
|
+
expect(getConversationByKey(legacyKey)).toBeNull();
|
|
569
|
+
// Self delete should keep external bindings in sync for the canonical route.
|
|
570
|
+
const remainingBinding = db.select()
|
|
571
|
+
.from(externalConversationBindings)
|
|
572
|
+
.where(eq(externalConversationBindings.conversationId, convId))
|
|
573
|
+
.get();
|
|
574
|
+
expect(remainingBinding).toBeUndefined();
|
|
575
|
+
});
|
|
447
576
|
});
|
|
@@ -52,6 +52,10 @@ import {
|
|
|
52
52
|
isGuardian,
|
|
53
53
|
revokeBinding as serviceRevokeBinding,
|
|
54
54
|
} from '../runtime/channel-guardian-service.js';
|
|
55
|
+
import { handleGuardianVerification } from '../daemon/handlers/config.js';
|
|
56
|
+
import type { GuardianVerificationRequest, GuardianVerificationResponse } from '../daemon/ipc-contract.js';
|
|
57
|
+
import type { HandlerContext } from '../daemon/handlers/shared.js';
|
|
58
|
+
import type * as net from 'node:net';
|
|
55
59
|
|
|
56
60
|
initializeDb();
|
|
57
61
|
|
|
@@ -949,7 +953,7 @@ describe('guardian service rate limiting', () => {
|
|
|
949
953
|
|
|
950
954
|
test('valid challenge still succeeds when under threshold', () => {
|
|
951
955
|
// Record a couple invalid attempts
|
|
952
|
-
const { secret } = createVerificationChallenge('asst-1', 'telegram');
|
|
956
|
+
const { secret: _secret } = createVerificationChallenge('asst-1', 'telegram');
|
|
953
957
|
validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-1', 'user-42', 'chat-42');
|
|
954
958
|
validateAndConsumeChallenge('asst-1', 'telegram', 'wrong-2', 'user-42', 'chat-42');
|
|
955
959
|
|
|
@@ -1003,3 +1007,369 @@ describe('guardian service rate limiting', () => {
|
|
|
1003
1007
|
expect(result.success).toBe(true);
|
|
1004
1008
|
});
|
|
1005
1009
|
});
|
|
1010
|
+
|
|
1011
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1012
|
+
// 8. Assistant-scoped guardian resolution
|
|
1013
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1014
|
+
|
|
1015
|
+
describe('assistant-scoped guardian resolution', () => {
|
|
1016
|
+
beforeEach(() => {
|
|
1017
|
+
resetTables();
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test('isGuardian resolves independently per assistantId', () => {
|
|
1021
|
+
// Create guardian binding for asst-A on telegram
|
|
1022
|
+
createBinding({
|
|
1023
|
+
assistantId: 'asst-A',
|
|
1024
|
+
channel: 'telegram',
|
|
1025
|
+
guardianExternalUserId: 'user-alpha',
|
|
1026
|
+
guardianDeliveryChatId: 'chat-alpha',
|
|
1027
|
+
});
|
|
1028
|
+
// Create guardian binding for asst-B on telegram with a different user
|
|
1029
|
+
createBinding({
|
|
1030
|
+
assistantId: 'asst-B',
|
|
1031
|
+
channel: 'telegram',
|
|
1032
|
+
guardianExternalUserId: 'user-beta',
|
|
1033
|
+
guardianDeliveryChatId: 'chat-beta',
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
// user-alpha is guardian for asst-A but not asst-B
|
|
1037
|
+
expect(isGuardian('asst-A', 'telegram', 'user-alpha')).toBe(true);
|
|
1038
|
+
expect(isGuardian('asst-B', 'telegram', 'user-alpha')).toBe(false);
|
|
1039
|
+
|
|
1040
|
+
// user-beta is guardian for asst-B but not asst-A
|
|
1041
|
+
expect(isGuardian('asst-B', 'telegram', 'user-beta')).toBe(true);
|
|
1042
|
+
expect(isGuardian('asst-A', 'telegram', 'user-beta')).toBe(false);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test('getGuardianBinding returns different bindings for different assistants', () => {
|
|
1046
|
+
createBinding({
|
|
1047
|
+
assistantId: 'asst-A',
|
|
1048
|
+
channel: 'telegram',
|
|
1049
|
+
guardianExternalUserId: 'user-alpha',
|
|
1050
|
+
guardianDeliveryChatId: 'chat-alpha',
|
|
1051
|
+
});
|
|
1052
|
+
createBinding({
|
|
1053
|
+
assistantId: 'asst-B',
|
|
1054
|
+
channel: 'telegram',
|
|
1055
|
+
guardianExternalUserId: 'user-beta',
|
|
1056
|
+
guardianDeliveryChatId: 'chat-beta',
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
const bindingA = getGuardianBinding('asst-A', 'telegram');
|
|
1060
|
+
const bindingB = getGuardianBinding('asst-B', 'telegram');
|
|
1061
|
+
|
|
1062
|
+
expect(bindingA).not.toBeNull();
|
|
1063
|
+
expect(bindingB).not.toBeNull();
|
|
1064
|
+
expect(bindingA!.guardianExternalUserId).toBe('user-alpha');
|
|
1065
|
+
expect(bindingB!.guardianExternalUserId).toBe('user-beta');
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test('revoking binding for one assistant does not affect another', () => {
|
|
1069
|
+
createBinding({
|
|
1070
|
+
assistantId: 'asst-A',
|
|
1071
|
+
channel: 'telegram',
|
|
1072
|
+
guardianExternalUserId: 'user-alpha',
|
|
1073
|
+
guardianDeliveryChatId: 'chat-alpha',
|
|
1074
|
+
});
|
|
1075
|
+
createBinding({
|
|
1076
|
+
assistantId: 'asst-B',
|
|
1077
|
+
channel: 'telegram',
|
|
1078
|
+
guardianExternalUserId: 'user-beta',
|
|
1079
|
+
guardianDeliveryChatId: 'chat-beta',
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
serviceRevokeBinding('asst-A', 'telegram');
|
|
1083
|
+
|
|
1084
|
+
expect(getGuardianBinding('asst-A', 'telegram')).toBeNull();
|
|
1085
|
+
expect(getGuardianBinding('asst-B', 'telegram')).not.toBeNull();
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
test('validateAndConsumeChallenge scoped to assistantId', () => {
|
|
1089
|
+
// Create challenge for asst-A
|
|
1090
|
+
const { secret: secretA } = createVerificationChallenge('asst-A', 'telegram');
|
|
1091
|
+
// Create challenge for asst-B
|
|
1092
|
+
const { secret: secretB } = createVerificationChallenge('asst-B', 'telegram');
|
|
1093
|
+
|
|
1094
|
+
// Attempting to consume asst-A challenge with asst-B should fail
|
|
1095
|
+
const crossResult = validateAndConsumeChallenge(
|
|
1096
|
+
'asst-B', 'telegram', secretA, 'user-1', 'chat-1',
|
|
1097
|
+
);
|
|
1098
|
+
expect(crossResult.success).toBe(false);
|
|
1099
|
+
|
|
1100
|
+
// Consuming with correct assistantId should succeed
|
|
1101
|
+
const resultA = validateAndConsumeChallenge(
|
|
1102
|
+
'asst-A', 'telegram', secretA, 'user-1', 'chat-1',
|
|
1103
|
+
);
|
|
1104
|
+
expect(resultA.success).toBe(true);
|
|
1105
|
+
|
|
1106
|
+
const resultB = validateAndConsumeChallenge(
|
|
1107
|
+
'asst-B', 'telegram', secretB, 'user-2', 'chat-2',
|
|
1108
|
+
);
|
|
1109
|
+
expect(resultB.success).toBe(true);
|
|
1110
|
+
|
|
1111
|
+
// Verify bindings are scoped correctly
|
|
1112
|
+
const bindingA = getGuardianBinding('asst-A', 'telegram');
|
|
1113
|
+
const bindingB = getGuardianBinding('asst-B', 'telegram');
|
|
1114
|
+
expect(bindingA!.guardianExternalUserId).toBe('user-1');
|
|
1115
|
+
expect(bindingB!.guardianExternalUserId).toBe('user-2');
|
|
1116
|
+
});
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1120
|
+
// 9. Assistant-scoped approval request lookups
|
|
1121
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1122
|
+
|
|
1123
|
+
describe('assistant-scoped approval request lookups', () => {
|
|
1124
|
+
beforeEach(() => {
|
|
1125
|
+
resetTables();
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
test('createApprovalRequest stores assistantId and defaults to self', () => {
|
|
1129
|
+
const reqWithoutId = createApprovalRequest({
|
|
1130
|
+
runId: 'run-1',
|
|
1131
|
+
conversationId: 'conv-1',
|
|
1132
|
+
channel: 'telegram',
|
|
1133
|
+
requesterExternalUserId: 'user-99',
|
|
1134
|
+
requesterChatId: 'chat-99',
|
|
1135
|
+
guardianExternalUserId: 'user-42',
|
|
1136
|
+
guardianChatId: 'chat-42',
|
|
1137
|
+
toolName: 'shell',
|
|
1138
|
+
expiresAt: Date.now() + 300_000,
|
|
1139
|
+
});
|
|
1140
|
+
expect(reqWithoutId.assistantId).toBe('self');
|
|
1141
|
+
|
|
1142
|
+
const reqWithId = createApprovalRequest({
|
|
1143
|
+
runId: 'run-2',
|
|
1144
|
+
conversationId: 'conv-2',
|
|
1145
|
+
assistantId: 'asst-A',
|
|
1146
|
+
channel: 'telegram',
|
|
1147
|
+
requesterExternalUserId: 'user-99',
|
|
1148
|
+
requesterChatId: 'chat-99',
|
|
1149
|
+
guardianExternalUserId: 'user-42',
|
|
1150
|
+
guardianChatId: 'chat-42',
|
|
1151
|
+
toolName: 'browser',
|
|
1152
|
+
expiresAt: Date.now() + 300_000,
|
|
1153
|
+
});
|
|
1154
|
+
expect(reqWithId.assistantId).toBe('asst-A');
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
test('approval requests from different assistants are independent', () => {
|
|
1158
|
+
createApprovalRequest({
|
|
1159
|
+
runId: 'run-A',
|
|
1160
|
+
conversationId: 'conv-A',
|
|
1161
|
+
assistantId: 'asst-A',
|
|
1162
|
+
channel: 'telegram',
|
|
1163
|
+
requesterExternalUserId: 'user-99',
|
|
1164
|
+
requesterChatId: 'chat-99',
|
|
1165
|
+
guardianExternalUserId: 'user-42',
|
|
1166
|
+
guardianChatId: 'chat-42',
|
|
1167
|
+
toolName: 'shell',
|
|
1168
|
+
expiresAt: Date.now() + 300_000,
|
|
1169
|
+
});
|
|
1170
|
+
createApprovalRequest({
|
|
1171
|
+
runId: 'run-B',
|
|
1172
|
+
conversationId: 'conv-B',
|
|
1173
|
+
assistantId: 'asst-B',
|
|
1174
|
+
channel: 'telegram',
|
|
1175
|
+
requesterExternalUserId: 'user-88',
|
|
1176
|
+
requesterChatId: 'chat-88',
|
|
1177
|
+
guardianExternalUserId: 'user-42',
|
|
1178
|
+
guardianChatId: 'chat-42',
|
|
1179
|
+
toolName: 'browser',
|
|
1180
|
+
expiresAt: Date.now() + 300_000,
|
|
1181
|
+
});
|
|
1182
|
+
|
|
1183
|
+
const foundA = getPendingApprovalForRun('run-A');
|
|
1184
|
+
const foundB = getPendingApprovalForRun('run-B');
|
|
1185
|
+
expect(foundA).not.toBeNull();
|
|
1186
|
+
expect(foundB).not.toBeNull();
|
|
1187
|
+
expect(foundA!.assistantId).toBe('asst-A');
|
|
1188
|
+
expect(foundB!.assistantId).toBe('asst-B');
|
|
1189
|
+
expect(foundA!.toolName).toBe('shell');
|
|
1190
|
+
expect(foundB!.toolName).toBe('browser');
|
|
1191
|
+
});
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1195
|
+
// 10. IPC handler — channel-aware guardian status response
|
|
1196
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Creates a minimal mock HandlerContext that captures the response sent via ctx.send().
|
|
1200
|
+
*/
|
|
1201
|
+
function createMockCtx(): { ctx: HandlerContext; lastResponse: () => GuardianVerificationResponse | null } {
|
|
1202
|
+
let captured: GuardianVerificationResponse | null = null;
|
|
1203
|
+
const ctx = {
|
|
1204
|
+
sessions: new Map(),
|
|
1205
|
+
socketToSession: new Map(),
|
|
1206
|
+
cuSessions: new Map(),
|
|
1207
|
+
socketToCuSession: new Map(),
|
|
1208
|
+
cuObservationParseSequence: new Map(),
|
|
1209
|
+
socketSandboxOverride: new Map(),
|
|
1210
|
+
sharedRequestTimestamps: [],
|
|
1211
|
+
debounceTimers: { schedule: () => {}, cancel: () => {} } as unknown as HandlerContext['debounceTimers'],
|
|
1212
|
+
suppressConfigReload: false,
|
|
1213
|
+
setSuppressConfigReload: () => {},
|
|
1214
|
+
updateConfigFingerprint: () => {},
|
|
1215
|
+
send: (_socket: net.Socket, msg: unknown) => { captured = msg as GuardianVerificationResponse; },
|
|
1216
|
+
broadcast: () => {},
|
|
1217
|
+
clearAllSessions: () => 0,
|
|
1218
|
+
getOrCreateSession: () => Promise.resolve({} as never),
|
|
1219
|
+
touchSession: () => {},
|
|
1220
|
+
} as unknown as HandlerContext;
|
|
1221
|
+
return { ctx, lastResponse: () => captured };
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const mockSocket = {} as net.Socket;
|
|
1225
|
+
|
|
1226
|
+
describe('IPC handler channel-aware guardian status', () => {
|
|
1227
|
+
beforeEach(() => {
|
|
1228
|
+
resetTables();
|
|
1229
|
+
});
|
|
1230
|
+
|
|
1231
|
+
test('status action for telegram returns channel and assistantId fields', () => {
|
|
1232
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1233
|
+
const msg: GuardianVerificationRequest = {
|
|
1234
|
+
type: 'guardian_verification',
|
|
1235
|
+
action: 'status',
|
|
1236
|
+
channel: 'telegram',
|
|
1237
|
+
assistantId: 'self',
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1240
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1241
|
+
|
|
1242
|
+
const resp = lastResponse();
|
|
1243
|
+
expect(resp).not.toBeNull();
|
|
1244
|
+
expect(resp!.success).toBe(true);
|
|
1245
|
+
expect(resp!.channel).toBe('telegram');
|
|
1246
|
+
expect(resp!.assistantId).toBe('self');
|
|
1247
|
+
expect(resp!.bound).toBe(false);
|
|
1248
|
+
expect(resp!.guardianDeliveryChatId).toBeUndefined();
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
test('status action for sms returns channel: sms and assistantId: self', () => {
|
|
1252
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1253
|
+
const msg: GuardianVerificationRequest = {
|
|
1254
|
+
type: 'guardian_verification',
|
|
1255
|
+
action: 'status',
|
|
1256
|
+
channel: 'sms',
|
|
1257
|
+
assistantId: 'self',
|
|
1258
|
+
};
|
|
1259
|
+
|
|
1260
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1261
|
+
|
|
1262
|
+
const resp = lastResponse();
|
|
1263
|
+
expect(resp).not.toBeNull();
|
|
1264
|
+
expect(resp!.success).toBe(true);
|
|
1265
|
+
expect(resp!.channel).toBe('sms');
|
|
1266
|
+
expect(resp!.assistantId).toBe('self');
|
|
1267
|
+
expect(resp!.bound).toBe(false);
|
|
1268
|
+
});
|
|
1269
|
+
|
|
1270
|
+
test('status action returns guardianDeliveryChatId when bound', () => {
|
|
1271
|
+
createBinding({
|
|
1272
|
+
assistantId: 'self',
|
|
1273
|
+
channel: 'telegram',
|
|
1274
|
+
guardianExternalUserId: 'user-42',
|
|
1275
|
+
guardianDeliveryChatId: 'chat-42',
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1279
|
+
const msg: GuardianVerificationRequest = {
|
|
1280
|
+
type: 'guardian_verification',
|
|
1281
|
+
action: 'status',
|
|
1282
|
+
channel: 'telegram',
|
|
1283
|
+
assistantId: 'self',
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1287
|
+
|
|
1288
|
+
const resp = lastResponse();
|
|
1289
|
+
expect(resp).not.toBeNull();
|
|
1290
|
+
expect(resp!.success).toBe(true);
|
|
1291
|
+
expect(resp!.bound).toBe(true);
|
|
1292
|
+
expect(resp!.guardianExternalUserId).toBe('user-42');
|
|
1293
|
+
expect(resp!.guardianDeliveryChatId).toBe('chat-42');
|
|
1294
|
+
expect(resp!.channel).toBe('telegram');
|
|
1295
|
+
expect(resp!.assistantId).toBe('self');
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
test('status action defaults channel to telegram when omitted (backward compat)', () => {
|
|
1299
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1300
|
+
const msg: GuardianVerificationRequest = {
|
|
1301
|
+
type: 'guardian_verification',
|
|
1302
|
+
action: 'status',
|
|
1303
|
+
// channel omitted — should default to 'telegram'
|
|
1304
|
+
};
|
|
1305
|
+
|
|
1306
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1307
|
+
|
|
1308
|
+
const resp = lastResponse();
|
|
1309
|
+
expect(resp).not.toBeNull();
|
|
1310
|
+
expect(resp!.channel).toBe('telegram');
|
|
1311
|
+
expect(resp!.assistantId).toBe('self');
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
test('status action defaults assistantId to self when omitted (backward compat)', () => {
|
|
1315
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1316
|
+
const msg: GuardianVerificationRequest = {
|
|
1317
|
+
type: 'guardian_verification',
|
|
1318
|
+
action: 'status',
|
|
1319
|
+
channel: 'sms',
|
|
1320
|
+
// assistantId omitted — should default to 'self'
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1324
|
+
|
|
1325
|
+
const resp = lastResponse();
|
|
1326
|
+
expect(resp).not.toBeNull();
|
|
1327
|
+
expect(resp!.assistantId).toBe('self');
|
|
1328
|
+
expect(resp!.channel).toBe('sms');
|
|
1329
|
+
});
|
|
1330
|
+
|
|
1331
|
+
test('status action with custom assistantId returns correct value', () => {
|
|
1332
|
+
createBinding({
|
|
1333
|
+
assistantId: 'asst-custom',
|
|
1334
|
+
channel: 'telegram',
|
|
1335
|
+
guardianExternalUserId: 'user-77',
|
|
1336
|
+
guardianDeliveryChatId: 'chat-77',
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1340
|
+
const msg: GuardianVerificationRequest = {
|
|
1341
|
+
type: 'guardian_verification',
|
|
1342
|
+
action: 'status',
|
|
1343
|
+
channel: 'telegram',
|
|
1344
|
+
assistantId: 'asst-custom',
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1348
|
+
|
|
1349
|
+
const resp = lastResponse();
|
|
1350
|
+
expect(resp).not.toBeNull();
|
|
1351
|
+
expect(resp!.success).toBe(true);
|
|
1352
|
+
expect(resp!.bound).toBe(true);
|
|
1353
|
+
expect(resp!.assistantId).toBe('asst-custom');
|
|
1354
|
+
expect(resp!.channel).toBe('telegram');
|
|
1355
|
+
expect(resp!.guardianExternalUserId).toBe('user-77');
|
|
1356
|
+
expect(resp!.guardianDeliveryChatId).toBe('chat-77');
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
test('status action for unbound sms does not return guardianDeliveryChatId', () => {
|
|
1360
|
+
const { ctx, lastResponse } = createMockCtx();
|
|
1361
|
+
const msg: GuardianVerificationRequest = {
|
|
1362
|
+
type: 'guardian_verification',
|
|
1363
|
+
action: 'status',
|
|
1364
|
+
channel: 'sms',
|
|
1365
|
+
};
|
|
1366
|
+
|
|
1367
|
+
handleGuardianVerification(msg, mockSocket, ctx);
|
|
1368
|
+
|
|
1369
|
+
const resp = lastResponse();
|
|
1370
|
+
expect(resp).not.toBeNull();
|
|
1371
|
+
expect(resp!.bound).toBe(false);
|
|
1372
|
+
expect(resp!.guardianDeliveryChatId).toBeUndefined();
|
|
1373
|
+
expect(resp!.guardianExternalUserId).toBeUndefined();
|
|
1374
|
+
});
|
|
1375
|
+
});
|
|
@@ -681,7 +681,7 @@ describe('AssistantConfigSchema', () => {
|
|
|
681
681
|
userConsultTimeoutSeconds: 120,
|
|
682
682
|
disclosure: {
|
|
683
683
|
enabled: true,
|
|
684
|
-
text: 'At the very beginning of the call,
|
|
684
|
+
text: 'At the very beginning of the call, introduce yourself as an assistant calling on behalf of the user.',
|
|
685
685
|
},
|
|
686
686
|
safety: {
|
|
687
687
|
denyCategories: [],
|
|
@@ -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
|