@vellumai/assistant 0.3.3 → 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 +8 -16
- package/package.json +1 -1
- package/src/__tests__/call-orchestrator.test.ts +321 -0
- package/src/__tests__/channel-approval-routes.test.ts +382 -124
- package/src/__tests__/channel-approvals.test.ts +51 -2
- package/src/__tests__/channel-delivery-store.test.ts +30 -4
- package/src/__tests__/channel-guardian.test.ts +187 -0
- package/src/__tests__/config-schema.test.ts +1 -1
- package/src/__tests__/daemon-lifecycle.test.ts +635 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
- package/src/__tests__/handlers-twilio-config.test.ts +73 -0
- package/src/__tests__/secret-scanner.test.ts +223 -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__/user-reference.test.ts +68 -0
- package/src/calls/call-orchestrator.ts +63 -11
- 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/messaging/SKILL.md +4 -0
- package/src/config/defaults.ts +1 -1
- package/src/config/schema.ts +3 -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 +38 -0
- 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 +36 -13
- package/src/daemon/handlers/skills.ts +6 -7
- package/src/daemon/ipc-contract.ts +6 -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 +5 -14
- package/src/daemon/session-runtime-assembly.ts +60 -44
- package/src/daemon/session.ts +8 -1
- package/src/memory/db-connection.ts +28 -0
- package/src/memory/db-init.ts +1019 -0
- package/src/memory/db.ts +2 -2007
- 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 +1 -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 +15 -4
- package/src/runtime/routes/channel-routes.ts +172 -84
- 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/frontmatter.ts +63 -0
- package/src/skills/slash-commands.ts +23 -0
- package/src/skills/vellum-catalog-remote.ts +107 -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 +61 -156
- package/src/tools/terminal/parser.ts +21 -5
- package/src/util/platform.ts +8 -1
- package/src/util/retry.ts +4 -4
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { describe, test, expect, beforeEach, afterAll,
|
|
1
|
+
import { describe, test, expect, beforeEach, afterAll, mock, spyOn } from 'bun:test';
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { eq } from 'drizzle-orm';
|
|
5
6
|
|
|
6
7
|
// ---------------------------------------------------------------------------
|
|
7
8
|
// Test isolation: in-memory SQLite via temp directory
|
|
@@ -41,12 +42,13 @@ mock.module('../daemon/handlers.js', () => ({
|
|
|
41
42
|
}));
|
|
42
43
|
|
|
43
44
|
import { initializeDb, getDb, resetDb } from '../memory/db.js';
|
|
44
|
-
import { conversations } from '../memory/schema.js';
|
|
45
|
+
import { conversations, externalConversationBindings } from '../memory/schema.js';
|
|
45
46
|
import {
|
|
46
47
|
createRun,
|
|
47
48
|
setRunConfirmation,
|
|
48
49
|
} from '../memory/runs-store.js';
|
|
49
50
|
import type { PendingConfirmation } from '../memory/runs-store.js';
|
|
51
|
+
import { setConversationKeyIfAbsent } from '../memory/conversation-key-store.js';
|
|
50
52
|
import * as channelDeliveryStore from '../memory/channel-delivery-store.js';
|
|
51
53
|
import * as conversationStore from '../memory/conversation-store.js';
|
|
52
54
|
import {
|
|
@@ -58,7 +60,6 @@ import {
|
|
|
58
60
|
import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
|
|
59
61
|
import {
|
|
60
62
|
handleChannelInbound,
|
|
61
|
-
isChannelApprovalsEnabled,
|
|
62
63
|
sweepExpiredGuardianApprovals,
|
|
63
64
|
verifyGatewayOrigin,
|
|
64
65
|
_setTestPollMaxWait,
|
|
@@ -94,6 +95,7 @@ function resetTables(): void {
|
|
|
94
95
|
db.run('DELETE FROM channel_guardian_approval_requests');
|
|
95
96
|
db.run('DELETE FROM channel_guardian_verification_challenges');
|
|
96
97
|
db.run('DELETE FROM channel_guardian_bindings');
|
|
98
|
+
db.run('DELETE FROM conversation_keys');
|
|
97
99
|
db.run('DELETE FROM message_runs');
|
|
98
100
|
db.run('DELETE FROM channel_inbound_events');
|
|
99
101
|
db.run('DELETE FROM messages');
|
|
@@ -141,6 +143,7 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
141
143
|
const body = {
|
|
142
144
|
sourceChannel: 'telegram',
|
|
143
145
|
externalChatId: 'chat-123',
|
|
146
|
+
senderExternalUserId: 'telegram-user-default',
|
|
144
147
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
145
148
|
content: 'hello',
|
|
146
149
|
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
@@ -158,53 +161,12 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
158
161
|
|
|
159
162
|
const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
|
|
160
163
|
|
|
161
|
-
// ---------------------------------------------------------------------------
|
|
162
|
-
// Set up / tear down feature flag for each test
|
|
163
|
-
// ---------------------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
let originalEnv: string | undefined;
|
|
166
|
-
|
|
167
164
|
beforeEach(() => {
|
|
168
165
|
resetTables();
|
|
169
|
-
originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
|
|
170
166
|
noopProcessMessage.mockClear();
|
|
171
167
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (originalEnv === undefined) {
|
|
175
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
176
|
-
} else {
|
|
177
|
-
process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
|
|
178
|
-
}
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
182
|
-
// 1. Feature flag gating
|
|
183
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
184
|
-
|
|
185
|
-
describe('isChannelApprovalsEnabled', () => {
|
|
186
|
-
test('returns false when env var is not set', () => {
|
|
187
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
188
|
-
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test('returns false when env var is "false"', () => {
|
|
192
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'false';
|
|
193
|
-
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
test('returns true when env var is "true"', () => {
|
|
197
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
198
|
-
expect(isChannelApprovalsEnabled()).toBe(true);
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
describe('feature flag disabled → normal flow', () => {
|
|
203
|
-
beforeEach(() => {
|
|
204
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test('proceeds normally even when pending approvals exist', async () => {
|
|
168
|
+
describe('stale callback handling without matching pending approval', () => {
|
|
169
|
+
test('ignores stale callback payloads even when pending approvals exist', async () => {
|
|
208
170
|
ensureConversation('conv-1');
|
|
209
171
|
const run = createRun('conv-1');
|
|
210
172
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
@@ -218,9 +180,10 @@ describe('feature flag disabled → normal flow', () => {
|
|
|
218
180
|
const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
|
|
219
181
|
const body = await res.json() as Record<string, unknown>;
|
|
220
182
|
|
|
221
|
-
//
|
|
183
|
+
// Callback payloads without a matching pending approval are treated as
|
|
184
|
+
// stale and ignored.
|
|
222
185
|
expect(body.accepted).toBe(true);
|
|
223
|
-
expect(body.approval).
|
|
186
|
+
expect(body.approval).toBe('stale_ignored');
|
|
224
187
|
});
|
|
225
188
|
});
|
|
226
189
|
|
|
@@ -230,7 +193,12 @@ describe('feature flag disabled → normal flow', () => {
|
|
|
230
193
|
|
|
231
194
|
describe('inbound callback metadata triggers decision handling', () => {
|
|
232
195
|
beforeEach(() => {
|
|
233
|
-
|
|
196
|
+
createBinding({
|
|
197
|
+
assistantId: 'self',
|
|
198
|
+
channel: 'telegram',
|
|
199
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
200
|
+
guardianDeliveryChatId: 'chat-123',
|
|
201
|
+
});
|
|
234
202
|
});
|
|
235
203
|
|
|
236
204
|
test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
|
|
@@ -322,7 +290,12 @@ describe('inbound callback metadata triggers decision handling', () => {
|
|
|
322
290
|
|
|
323
291
|
describe('inbound text matching approval phrases triggers decision handling', () => {
|
|
324
292
|
beforeEach(() => {
|
|
325
|
-
|
|
293
|
+
createBinding({
|
|
294
|
+
assistantId: 'self',
|
|
295
|
+
channel: 'telegram',
|
|
296
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
297
|
+
guardianDeliveryChatId: 'chat-123',
|
|
298
|
+
});
|
|
326
299
|
});
|
|
327
300
|
|
|
328
301
|
test('text "approve" triggers approve_once decision', async () => {
|
|
@@ -387,7 +360,12 @@ describe('inbound text matching approval phrases triggers decision handling', ()
|
|
|
387
360
|
|
|
388
361
|
describe('non-decision messages during pending approval trigger reminder', () => {
|
|
389
362
|
beforeEach(() => {
|
|
390
|
-
|
|
363
|
+
createBinding({
|
|
364
|
+
assistantId: 'self',
|
|
365
|
+
channel: 'telegram',
|
|
366
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
367
|
+
guardianDeliveryChatId: 'chat-123',
|
|
368
|
+
});
|
|
391
369
|
});
|
|
392
370
|
|
|
393
371
|
test('sends a reminder prompt when message is not a decision', async () => {
|
|
@@ -435,7 +413,6 @@ describe('non-decision messages during pending approval trigger reminder', () =>
|
|
|
435
413
|
|
|
436
414
|
describe('messages without pending approval proceed normally', () => {
|
|
437
415
|
beforeEach(() => {
|
|
438
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
439
416
|
});
|
|
440
417
|
|
|
441
418
|
test('proceeds to normal processing when no pending approval exists', async () => {
|
|
@@ -479,7 +456,6 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
479
456
|
});
|
|
480
457
|
|
|
481
458
|
test('allows empty content when callbackData is present', async () => {
|
|
482
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
483
459
|
const orchestrator = makeMockOrchestrator();
|
|
484
460
|
|
|
485
461
|
// Establish the conversation first
|
|
@@ -512,7 +488,6 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
512
488
|
});
|
|
513
489
|
|
|
514
490
|
test('allows undefined content when callbackData is present', async () => {
|
|
515
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
516
491
|
const orchestrator = makeMockOrchestrator();
|
|
517
492
|
|
|
518
493
|
// Establish the conversation first
|
|
@@ -561,7 +536,12 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
561
536
|
|
|
562
537
|
describe('callback run ID validation', () => {
|
|
563
538
|
beforeEach(() => {
|
|
564
|
-
|
|
539
|
+
createBinding({
|
|
540
|
+
assistantId: 'self',
|
|
541
|
+
channel: 'telegram',
|
|
542
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
543
|
+
guardianDeliveryChatId: 'chat-123',
|
|
544
|
+
});
|
|
565
545
|
});
|
|
566
546
|
|
|
567
547
|
test('ignores stale callback when run ID does not match pending run', async () => {
|
|
@@ -669,7 +649,6 @@ describe('callback run ID validation', () => {
|
|
|
669
649
|
|
|
670
650
|
describe('linkMessage in approval-aware processing path', () => {
|
|
671
651
|
beforeEach(() => {
|
|
672
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
673
652
|
});
|
|
674
653
|
|
|
675
654
|
test('linkMessage is called when run has a messageId and reaches terminal state', async () => {
|
|
@@ -726,7 +705,6 @@ describe('linkMessage in approval-aware processing path', () => {
|
|
|
726
705
|
|
|
727
706
|
describe('terminal state check before markProcessed', () => {
|
|
728
707
|
beforeEach(() => {
|
|
729
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
730
708
|
});
|
|
731
709
|
|
|
732
710
|
test('records processing failure when run disappears (non-approval non-terminal state)', async () => {
|
|
@@ -865,7 +843,12 @@ describe('terminal state check before markProcessed', () => {
|
|
|
865
843
|
|
|
866
844
|
describe('no immediate reply after approval decision', () => {
|
|
867
845
|
beforeEach(() => {
|
|
868
|
-
|
|
846
|
+
createBinding({
|
|
847
|
+
assistantId: 'self',
|
|
848
|
+
channel: 'telegram',
|
|
849
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
850
|
+
guardianDeliveryChatId: 'chat-123',
|
|
851
|
+
});
|
|
869
852
|
});
|
|
870
853
|
|
|
871
854
|
test('deliverChannelReply is NOT called from interception after decision is applied', async () => {
|
|
@@ -943,7 +926,6 @@ describe('no immediate reply after approval decision', () => {
|
|
|
943
926
|
|
|
944
927
|
describe('stale callback handling', () => {
|
|
945
928
|
beforeEach(() => {
|
|
946
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
947
929
|
});
|
|
948
930
|
|
|
949
931
|
test('callback with no pending approval returns stale_ignored and does not start a run', async () => {
|
|
@@ -1007,7 +989,6 @@ describe('stale callback handling', () => {
|
|
|
1007
989
|
|
|
1008
990
|
describe('poll timeout handling by run state', () => {
|
|
1009
991
|
beforeEach(() => {
|
|
1010
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1011
992
|
});
|
|
1012
993
|
|
|
1013
994
|
test('records processing failure when run disappears (getRun returns null) before terminal state', async () => {
|
|
@@ -1060,8 +1041,8 @@ describe('poll timeout handling by run state', () => {
|
|
|
1060
1041
|
|
|
1061
1042
|
test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
|
|
1062
1043
|
// Use a short poll timeout so the test can exercise the timeout path
|
|
1063
|
-
// without waiting 5 minutes.
|
|
1064
|
-
_setTestPollMaxWait(
|
|
1044
|
+
// without waiting 5 minutes. Must exceed one poll interval (500ms).
|
|
1045
|
+
_setTestPollMaxWait(700);
|
|
1065
1046
|
|
|
1066
1047
|
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1067
1048
|
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
@@ -1083,20 +1064,26 @@ describe('poll timeout handling by run state', () => {
|
|
|
1083
1064
|
updatedAt: Date.now(),
|
|
1084
1065
|
};
|
|
1085
1066
|
|
|
1086
|
-
// getRun returns
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1067
|
+
// getRun returns null on the first call (causing the poll loop to break
|
|
1068
|
+
// immediately), then returns needs_confirmation on the post-loop call.
|
|
1069
|
+
// This exercises the timeout path deterministically without spinning for
|
|
1070
|
+
// the full poll duration.
|
|
1071
|
+
let getRunCalls = 0;
|
|
1089
1072
|
const orchestrator = {
|
|
1090
1073
|
submitDecision: mock(() => 'applied' as const),
|
|
1091
|
-
getRun: mock(() =>
|
|
1074
|
+
getRun: mock(() => {
|
|
1075
|
+
getRunCalls++;
|
|
1076
|
+
if (getRunCalls <= 1) return null;
|
|
1077
|
+
return { ...mockRun, status: 'needs_confirmation' as const };
|
|
1078
|
+
}),
|
|
1092
1079
|
startRun: mock(async () => mockRun),
|
|
1093
1080
|
} as unknown as RunOrchestrator;
|
|
1094
1081
|
|
|
1095
1082
|
const req = makeInboundRequest({ content: 'hello needs_confirm' });
|
|
1096
1083
|
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1097
1084
|
|
|
1098
|
-
// Wait for the background async to complete
|
|
1099
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
1085
|
+
// Wait for the background async to complete
|
|
1086
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
1100
1087
|
|
|
1101
1088
|
// markProcessed SHOULD have been called — the run is waiting for approval,
|
|
1102
1089
|
// and the post-decision delivery path will handle the final reply.
|
|
@@ -1112,6 +1099,125 @@ describe('poll timeout handling by run state', () => {
|
|
|
1112
1099
|
_setTestPollMaxWait(null);
|
|
1113
1100
|
});
|
|
1114
1101
|
|
|
1102
|
+
test('marks event as processed when run transitions from needs_confirmation to running at poll timeout', async () => {
|
|
1103
|
+
// When an approval is applied near the poll deadline, the run transitions
|
|
1104
|
+
// from needs_confirmation to running. The post-decision delivery path
|
|
1105
|
+
// in handleApprovalInterception handles the final reply, so the main poll
|
|
1106
|
+
// should mark the event as processed rather than recording a failure.
|
|
1107
|
+
//
|
|
1108
|
+
// The hasPostDecisionDelivery flag is only set when the approval prompt
|
|
1109
|
+
// is actually delivered successfully — not in auto-deny paths. This test
|
|
1110
|
+
// sets up a guardian actor with a real DB run so the standard approval
|
|
1111
|
+
// prompt is delivered and the flag is set.
|
|
1112
|
+
_setTestPollMaxWait(100);
|
|
1113
|
+
|
|
1114
|
+
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1115
|
+
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1116
|
+
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
1117
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1118
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1119
|
+
|
|
1120
|
+
// Set up a guardian binding so the sender is a guardian (standard approval
|
|
1121
|
+
// path, not auto-deny). This ensures the approval prompt is delivered and
|
|
1122
|
+
// hasPostDecisionDelivery is set to true.
|
|
1123
|
+
createBinding({
|
|
1124
|
+
assistantId: 'self',
|
|
1125
|
+
channel: 'telegram',
|
|
1126
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
1127
|
+
guardianDeliveryChatId: 'chat-123',
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
const conversationId = `conv-post-approval-${Date.now()}`;
|
|
1131
|
+
ensureConversation(conversationId);
|
|
1132
|
+
setConversationKeyIfAbsent('asst:self:telegram:chat-123', conversationId);
|
|
1133
|
+
setConversationKeyIfAbsent('telegram:chat-123', conversationId);
|
|
1134
|
+
|
|
1135
|
+
let realRunId: string | undefined;
|
|
1136
|
+
|
|
1137
|
+
// Simulate a run that transitions from needs_confirmation back to running
|
|
1138
|
+
// (approval applied) before the poll exits, then stays running past timeout.
|
|
1139
|
+
let getRunCalls = 0;
|
|
1140
|
+
const orchestrator = {
|
|
1141
|
+
submitDecision: mock(() => 'applied' as const),
|
|
1142
|
+
getRun: mock(() => {
|
|
1143
|
+
getRunCalls++;
|
|
1144
|
+
// First call inside the loop: needs_confirmation (triggers approval prompt delivery)
|
|
1145
|
+
if (getRunCalls <= 1) return {
|
|
1146
|
+
id: realRunId ?? 'run-post-approval',
|
|
1147
|
+
conversationId,
|
|
1148
|
+
messageId: 'user-msg-203',
|
|
1149
|
+
status: 'needs_confirmation' as const,
|
|
1150
|
+
pendingConfirmation: sampleConfirmation,
|
|
1151
|
+
pendingSecret: null,
|
|
1152
|
+
inputTokens: 0,
|
|
1153
|
+
outputTokens: 0,
|
|
1154
|
+
estimatedCost: 0,
|
|
1155
|
+
error: null,
|
|
1156
|
+
createdAt: Date.now(),
|
|
1157
|
+
updatedAt: Date.now(),
|
|
1158
|
+
};
|
|
1159
|
+
// Subsequent calls: running (approval was applied, run resumed)
|
|
1160
|
+
return {
|
|
1161
|
+
id: realRunId ?? 'run-post-approval',
|
|
1162
|
+
conversationId,
|
|
1163
|
+
messageId: 'user-msg-203',
|
|
1164
|
+
status: 'running' as const,
|
|
1165
|
+
pendingConfirmation: null,
|
|
1166
|
+
pendingSecret: null,
|
|
1167
|
+
inputTokens: 0,
|
|
1168
|
+
outputTokens: 0,
|
|
1169
|
+
estimatedCost: 0,
|
|
1170
|
+
error: null,
|
|
1171
|
+
createdAt: Date.now(),
|
|
1172
|
+
updatedAt: Date.now(),
|
|
1173
|
+
};
|
|
1174
|
+
}),
|
|
1175
|
+
startRun: mock(async (_convId: string) => {
|
|
1176
|
+
const run = createRun(conversationId);
|
|
1177
|
+
realRunId = run.id;
|
|
1178
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
1179
|
+
return {
|
|
1180
|
+
id: run.id,
|
|
1181
|
+
conversationId,
|
|
1182
|
+
messageId: null,
|
|
1183
|
+
status: 'running' as const,
|
|
1184
|
+
pendingConfirmation: null,
|
|
1185
|
+
pendingSecret: null,
|
|
1186
|
+
inputTokens: 0,
|
|
1187
|
+
outputTokens: 0,
|
|
1188
|
+
estimatedCost: 0,
|
|
1189
|
+
error: null,
|
|
1190
|
+
createdAt: Date.now(),
|
|
1191
|
+
updatedAt: Date.now(),
|
|
1192
|
+
};
|
|
1193
|
+
}),
|
|
1194
|
+
} as unknown as RunOrchestrator;
|
|
1195
|
+
|
|
1196
|
+
const req = makeInboundRequest({ content: 'hello post-approval running' });
|
|
1197
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
1198
|
+
|
|
1199
|
+
// Wait for background async to complete (poll timeout + buffer)
|
|
1200
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
1201
|
+
|
|
1202
|
+
// The approval prompt should have been delivered (standard path for guardian actor)
|
|
1203
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
1204
|
+
|
|
1205
|
+
// markProcessed SHOULD have been called — the approval prompt was delivered
|
|
1206
|
+
// (hasPostDecisionDelivery is true) and the run transitioned to running
|
|
1207
|
+
// (post-approval), so the post-decision delivery path handles the final reply.
|
|
1208
|
+
expect(markSpy).toHaveBeenCalled();
|
|
1209
|
+
|
|
1210
|
+
// recordProcessingFailure should NOT have been called
|
|
1211
|
+
expect(failureSpy).not.toHaveBeenCalled();
|
|
1212
|
+
|
|
1213
|
+
linkSpy.mockRestore();
|
|
1214
|
+
markSpy.mockRestore();
|
|
1215
|
+
failureSpy.mockRestore();
|
|
1216
|
+
deliverSpy.mockRestore();
|
|
1217
|
+
approvalSpy.mockRestore();
|
|
1218
|
+
_setTestPollMaxWait(null);
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1115
1221
|
test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
|
|
1116
1222
|
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1117
1223
|
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
@@ -1165,7 +1271,6 @@ describe('poll timeout handling by run state', () => {
|
|
|
1165
1271
|
|
|
1166
1272
|
describe('post-decision delivery after poll timeout', () => {
|
|
1167
1273
|
beforeEach(() => {
|
|
1168
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1169
1274
|
});
|
|
1170
1275
|
|
|
1171
1276
|
test('delivers reply via callback after a late approval decision', async () => {
|
|
@@ -1279,7 +1384,6 @@ describe('post-decision delivery after poll timeout', () => {
|
|
|
1279
1384
|
|
|
1280
1385
|
describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
1281
1386
|
beforeEach(() => {
|
|
1282
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1283
1387
|
});
|
|
1284
1388
|
|
|
1285
1389
|
test('startRun is called with sourceChannel from inbound event', async () => {
|
|
@@ -1337,13 +1441,19 @@ describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
|
1337
1441
|
|
|
1338
1442
|
describe('SMS channel approval decisions', () => {
|
|
1339
1443
|
beforeEach(() => {
|
|
1340
|
-
|
|
1444
|
+
createBinding({
|
|
1445
|
+
assistantId: 'self',
|
|
1446
|
+
channel: 'sms',
|
|
1447
|
+
guardianExternalUserId: 'sms-user-default',
|
|
1448
|
+
guardianDeliveryChatId: 'sms-chat-123',
|
|
1449
|
+
});
|
|
1341
1450
|
});
|
|
1342
1451
|
|
|
1343
1452
|
function makeSmsInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
1344
1453
|
const body = {
|
|
1345
1454
|
sourceChannel: 'sms',
|
|
1346
1455
|
externalChatId: 'sms-chat-123',
|
|
1456
|
+
senderExternalUserId: 'sms-user-default',
|
|
1347
1457
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1348
1458
|
content: 'hello',
|
|
1349
1459
|
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
@@ -1570,7 +1680,6 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1570
1680
|
|
|
1571
1681
|
describe('SMS non-guardian actor gating', () => {
|
|
1572
1682
|
beforeEach(() => {
|
|
1573
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1574
1683
|
});
|
|
1575
1684
|
|
|
1576
1685
|
test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
|
|
@@ -1644,7 +1753,18 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1644
1753
|
|
|
1645
1754
|
describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
1646
1755
|
beforeEach(() => {
|
|
1647
|
-
|
|
1756
|
+
createBinding({
|
|
1757
|
+
assistantId: 'self',
|
|
1758
|
+
channel: 'telegram',
|
|
1759
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
1760
|
+
guardianDeliveryChatId: 'chat-123',
|
|
1761
|
+
});
|
|
1762
|
+
createBinding({
|
|
1763
|
+
assistantId: 'self',
|
|
1764
|
+
channel: 'http-api',
|
|
1765
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
1766
|
+
guardianDeliveryChatId: 'chat-123',
|
|
1767
|
+
});
|
|
1648
1768
|
});
|
|
1649
1769
|
|
|
1650
1770
|
test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
|
|
@@ -1800,10 +1920,9 @@ function makeSensitiveOrchestrator(opts: {
|
|
|
1800
1920
|
|
|
1801
1921
|
describe('fail-closed guardian gate — unverified channel', () => {
|
|
1802
1922
|
beforeEach(() => {
|
|
1803
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1804
1923
|
});
|
|
1805
1924
|
|
|
1806
|
-
test('no binding + sensitive action → auto-deny
|
|
1925
|
+
test('no binding + sensitive action → auto-deny with contextual assistant guidance', async () => {
|
|
1807
1926
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1808
1927
|
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1809
1928
|
|
|
@@ -1823,11 +1942,15 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1823
1942
|
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
1824
1943
|
expect(decisionArgs[1]).toBe('deny');
|
|
1825
1944
|
|
|
1826
|
-
// The
|
|
1827
|
-
|
|
1945
|
+
// The deny decision should carry guardian setup context for assistant reply generation.
|
|
1946
|
+
expect(typeof decisionArgs[2]).toBe('string');
|
|
1947
|
+
expect((decisionArgs[2] as string)).toContain('no guardian is configured');
|
|
1948
|
+
|
|
1949
|
+
// The runtime should not send a second deterministic denial notice.
|
|
1950
|
+
const deterministicNoticeCalls = deliverSpy.mock.calls.filter(
|
|
1828
1951
|
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
|
|
1829
1952
|
);
|
|
1830
|
-
expect(
|
|
1953
|
+
expect(deterministicNoticeCalls.length).toBe(0);
|
|
1831
1954
|
|
|
1832
1955
|
// No approval prompt should have been sent to a guardian (none exists)
|
|
1833
1956
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -1916,11 +2039,19 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1916
2039
|
expect(body.accepted).toBe(true);
|
|
1917
2040
|
expect(body.approval).toBe('decision_applied');
|
|
1918
2041
|
|
|
1919
|
-
// The
|
|
2042
|
+
// The deny decision should carry guardian setup context for the assistant.
|
|
2043
|
+
const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
|
|
2044
|
+
expect(submitCalls.length).toBeGreaterThanOrEqual(1);
|
|
2045
|
+
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
2046
|
+
expect(lastDecision[1]).toBe('deny');
|
|
2047
|
+
expect(typeof lastDecision[2]).toBe('string');
|
|
2048
|
+
expect((lastDecision[2] as string)).toContain('no guardian is configured');
|
|
2049
|
+
|
|
2050
|
+
// Interception should not emit a separate deterministic denial notice.
|
|
1920
2051
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
1921
2052
|
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
|
|
1922
2053
|
);
|
|
1923
|
-
expect(denialCalls.length).
|
|
2054
|
+
expect(denialCalls.length).toBe(0);
|
|
1924
2055
|
|
|
1925
2056
|
deliverSpy.mockRestore();
|
|
1926
2057
|
});
|
|
@@ -1932,7 +2063,6 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1932
2063
|
|
|
1933
2064
|
describe('guardian-with-binding path regression', () => {
|
|
1934
2065
|
beforeEach(() => {
|
|
1935
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1936
2066
|
});
|
|
1937
2067
|
|
|
1938
2068
|
test('non-guardian with binding routes approval to guardian chat', async () => {
|
|
@@ -2004,6 +2134,53 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2004
2134
|
deliverSpy.mockRestore();
|
|
2005
2135
|
approvalSpy.mockRestore();
|
|
2006
2136
|
});
|
|
2137
|
+
|
|
2138
|
+
test('guardian callback for own pending run is handled by standard interception', async () => {
|
|
2139
|
+
createBinding({
|
|
2140
|
+
assistantId: 'self',
|
|
2141
|
+
channel: 'telegram',
|
|
2142
|
+
guardianExternalUserId: 'guardian-user-self-callback',
|
|
2143
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
const orchestrator = makeMockOrchestrator();
|
|
2147
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2148
|
+
|
|
2149
|
+
// Establish the conversation mapping for chat-123.
|
|
2150
|
+
const initReq = makeInboundRequest({
|
|
2151
|
+
content: 'init',
|
|
2152
|
+
senderExternalUserId: 'guardian-user-self-callback',
|
|
2153
|
+
externalChatId: 'chat-123',
|
|
2154
|
+
});
|
|
2155
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
2156
|
+
|
|
2157
|
+
const db = getDb();
|
|
2158
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
2159
|
+
const conversationId = events[0]?.conversation_id;
|
|
2160
|
+
ensureConversation(conversationId!);
|
|
2161
|
+
|
|
2162
|
+
const run = createRun(conversationId!);
|
|
2163
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2164
|
+
|
|
2165
|
+
// Button callback includes a runId but there is no guardian approval request
|
|
2166
|
+
// because this is the guardian's own approval flow.
|
|
2167
|
+
const req = makeInboundRequest({
|
|
2168
|
+
content: '',
|
|
2169
|
+
senderExternalUserId: 'guardian-user-self-callback',
|
|
2170
|
+
externalChatId: 'chat-123',
|
|
2171
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
2172
|
+
});
|
|
2173
|
+
|
|
2174
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2175
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2176
|
+
|
|
2177
|
+
expect(body.accepted).toBe(true);
|
|
2178
|
+
expect(body.approval).toBe('decision_applied');
|
|
2179
|
+
expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'allow');
|
|
2180
|
+
expect(getPendingApprovalForRun(run.id)).toBeNull();
|
|
2181
|
+
|
|
2182
|
+
deliverSpy.mockRestore();
|
|
2183
|
+
});
|
|
2007
2184
|
});
|
|
2008
2185
|
|
|
2009
2186
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -2012,7 +2189,6 @@ describe('guardian-with-binding path regression', () => {
|
|
|
2012
2189
|
|
|
2013
2190
|
describe('guardian delivery failure → denial', () => {
|
|
2014
2191
|
beforeEach(() => {
|
|
2015
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2016
2192
|
});
|
|
2017
2193
|
|
|
2018
2194
|
test('delivery failure denies run and notifies requester', async () => {
|
|
@@ -2108,7 +2284,6 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2108
2284
|
|
|
2109
2285
|
describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
2110
2286
|
beforeEach(() => {
|
|
2111
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2112
2287
|
});
|
|
2113
2288
|
|
|
2114
2289
|
test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
|
|
@@ -2154,7 +2329,6 @@ describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
|
2154
2329
|
|
|
2155
2330
|
describe('guardian decision scoping — multiple pending approvals', () => {
|
|
2156
2331
|
beforeEach(() => {
|
|
2157
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2158
2332
|
});
|
|
2159
2333
|
|
|
2160
2334
|
test('callback for older run resolves to the correct approval request', async () => {
|
|
@@ -2241,7 +2415,6 @@ describe('guardian decision scoping — multiple pending approvals', () => {
|
|
|
2241
2415
|
|
|
2242
2416
|
describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
2243
2417
|
beforeEach(() => {
|
|
2244
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2245
2418
|
});
|
|
2246
2419
|
|
|
2247
2420
|
test('does not apply plain-text decision to wrong run when multiple pending', async () => {
|
|
@@ -2328,7 +2501,6 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2328
2501
|
|
|
2329
2502
|
describe('expired guardian approval auto-denies via sweep', () => {
|
|
2330
2503
|
beforeEach(() => {
|
|
2331
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2332
2504
|
});
|
|
2333
2505
|
|
|
2334
2506
|
test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
|
|
@@ -2472,7 +2644,12 @@ describe('deliver-once idempotency guard', () => {
|
|
|
2472
2644
|
|
|
2473
2645
|
describe('final reply idempotency — no duplicate delivery', () => {
|
|
2474
2646
|
beforeEach(() => {
|
|
2475
|
-
|
|
2647
|
+
createBinding({
|
|
2648
|
+
assistantId: 'self',
|
|
2649
|
+
channel: 'telegram',
|
|
2650
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
2651
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2652
|
+
});
|
|
2476
2653
|
});
|
|
2477
2654
|
|
|
2478
2655
|
test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
|
|
@@ -2766,7 +2943,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
2766
2943
|
});
|
|
2767
2944
|
|
|
2768
2945
|
test('actor role resolution uses threaded assistantId', async () => {
|
|
2769
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2770
2946
|
|
|
2771
2947
|
// Create guardian binding for asst-role-X
|
|
2772
2948
|
createBinding({
|
|
@@ -2800,7 +2976,6 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
2800
2976
|
});
|
|
2801
2977
|
|
|
2802
2978
|
test('same user is guardian for one assistant but not another', async () => {
|
|
2803
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2804
2979
|
|
|
2805
2980
|
// user-multi is guardian for asst-M1 but not asst-M2
|
|
2806
2981
|
createBinding({
|
|
@@ -2856,16 +3031,87 @@ describe('assistant-scoped guardian verification via handleChannelInbound', () =
|
|
|
2856
3031
|
deliverSpy.mockRestore();
|
|
2857
3032
|
approvalSpy.mockRestore();
|
|
2858
3033
|
});
|
|
3034
|
+
|
|
3035
|
+
test('non-self assistant inbound does not mutate assistant-agnostic external bindings', async () => {
|
|
3036
|
+
const db = getDb();
|
|
3037
|
+
const now = Date.now();
|
|
3038
|
+
ensureConversation('conv-existing-binding');
|
|
3039
|
+
db.insert(externalConversationBindings).values({
|
|
3040
|
+
conversationId: 'conv-existing-binding',
|
|
3041
|
+
sourceChannel: 'telegram',
|
|
3042
|
+
externalChatId: 'chat-123',
|
|
3043
|
+
externalUserId: 'existing-user',
|
|
3044
|
+
createdAt: now,
|
|
3045
|
+
updatedAt: now,
|
|
3046
|
+
lastInboundAt: now,
|
|
3047
|
+
}).run();
|
|
3048
|
+
|
|
3049
|
+
const req = makeInboundRequest({
|
|
3050
|
+
content: 'hello from non-self assistant',
|
|
3051
|
+
senderExternalUserId: 'incoming-user',
|
|
3052
|
+
});
|
|
3053
|
+
|
|
3054
|
+
const res = await handleChannelInbound(req, undefined, 'token', undefined, 'asst-non-self');
|
|
3055
|
+
expect(res.status).toBe(200);
|
|
3056
|
+
|
|
3057
|
+
const binding = db
|
|
3058
|
+
.select()
|
|
3059
|
+
.from(externalConversationBindings)
|
|
3060
|
+
.where(eq(externalConversationBindings.conversationId, 'conv-existing-binding'))
|
|
3061
|
+
.get();
|
|
3062
|
+
expect(binding).not.toBeNull();
|
|
3063
|
+
expect(binding!.externalUserId).toBe('existing-user');
|
|
3064
|
+
});
|
|
2859
3065
|
});
|
|
2860
3066
|
|
|
2861
3067
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2862
|
-
// 27. Guardian enforcement
|
|
3068
|
+
// 27. Guardian enforcement behavior
|
|
2863
3069
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2864
3070
|
|
|
2865
|
-
describe('guardian enforcement
|
|
2866
|
-
test('
|
|
2867
|
-
|
|
3071
|
+
describe('guardian enforcement behavior', () => {
|
|
3072
|
+
test('guardian sender on telegram uses approval-aware path', async () => {
|
|
3073
|
+
|
|
3074
|
+
// Default senderExternalUserId in makeInboundRequest is telegram-user-default.
|
|
3075
|
+
createBinding({
|
|
3076
|
+
assistantId: 'self',
|
|
3077
|
+
channel: 'telegram',
|
|
3078
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
3079
|
+
guardianDeliveryChatId: 'chat-123',
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
const processSpy = mock(async () => ({ messageId: 'msg-bg-guardian' }));
|
|
3083
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3084
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3085
|
+
|
|
3086
|
+
const orchestrator = makeSensitiveOrchestrator({
|
|
3087
|
+
runId: 'run-guardian-flag-off-telegram',
|
|
3088
|
+
terminalStatus: 'completed',
|
|
3089
|
+
});
|
|
3090
|
+
|
|
3091
|
+
const req = makeInboundRequest({
|
|
3092
|
+
content: 'place a call',
|
|
3093
|
+
senderExternalUserId: 'telegram-user-default',
|
|
3094
|
+
sourceChannel: 'telegram',
|
|
3095
|
+
});
|
|
3096
|
+
|
|
3097
|
+
const res = await handleChannelInbound(req, processSpy, 'token', orchestrator);
|
|
3098
|
+
expect(res.status).toBe(200);
|
|
3099
|
+
|
|
3100
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3101
|
+
|
|
3102
|
+
// Regression guard: this must use the orchestrator approval path, not
|
|
3103
|
+
// fire-and-forget processMessage, otherwise prompts can time out.
|
|
3104
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3105
|
+
expect(processSpy).not.toHaveBeenCalled();
|
|
2868
3106
|
|
|
3107
|
+
// Guardian self-approval prompt should be delivered to the requester's chat.
|
|
3108
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3109
|
+
|
|
3110
|
+
approvalSpy.mockRestore();
|
|
3111
|
+
deliverSpy.mockRestore();
|
|
3112
|
+
});
|
|
3113
|
+
|
|
3114
|
+
test('non-guardian sensitive action routes approval to guardian', async () => {
|
|
2869
3115
|
// Create a guardian binding — user-guardian is the guardian
|
|
2870
3116
|
createBinding({
|
|
2871
3117
|
assistantId: 'self',
|
|
@@ -2874,31 +3120,28 @@ describe('guardian enforcement independence from approval flag', () => {
|
|
|
2874
3120
|
guardianDeliveryChatId: 'chat-guardian',
|
|
2875
3121
|
});
|
|
2876
3122
|
|
|
2877
|
-
|
|
2878
|
-
// Actor role resolution should still classify them as non-guardian
|
|
2879
|
-
// even though the approval UX is off.
|
|
2880
|
-
const orchestrator = makeMockOrchestrator();
|
|
3123
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-flag-off-guardian', terminalStatus: 'completed' });
|
|
2881
3124
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3125
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2882
3126
|
|
|
2883
3127
|
const req = makeInboundRequest({
|
|
2884
|
-
content: '
|
|
3128
|
+
content: 'do something dangerous',
|
|
2885
3129
|
senderExternalUserId: 'user-non-guardian',
|
|
2886
3130
|
});
|
|
2887
3131
|
|
|
2888
3132
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2889
3133
|
expect(res.status).toBe(200);
|
|
3134
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2890
3135
|
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
const body = await res.json() as Record<string, unknown>;
|
|
2895
|
-
expect(body.accepted).toBe(true);
|
|
3136
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3137
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
3138
|
+
expect(approvalArgs[1]).toBe('chat-guardian');
|
|
2896
3139
|
|
|
2897
3140
|
deliverSpy.mockRestore();
|
|
3141
|
+
approvalSpy.mockRestore();
|
|
2898
3142
|
});
|
|
2899
3143
|
|
|
2900
3144
|
test('missing senderExternalUserId with guardian binding fails closed', async () => {
|
|
2901
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2902
3145
|
|
|
2903
3146
|
// Create a guardian binding — guardian enforcement is active
|
|
2904
3147
|
createBinding({
|
|
@@ -2928,20 +3171,21 @@ describe('guardian enforcement independence from approval flag', () => {
|
|
|
2928
3171
|
// Wait for background processing
|
|
2929
3172
|
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2930
3173
|
|
|
2931
|
-
// The unknown actor should be treated as unverified_channel and
|
|
2932
|
-
//
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
3174
|
+
// The unknown actor should be treated as unverified_channel and denied,
|
|
3175
|
+
// with context passed into the tool-denial response for assistant phrasing.
|
|
3176
|
+
const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
|
|
3177
|
+
expect(submitCalls.length).toBeGreaterThanOrEqual(1);
|
|
3178
|
+
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3179
|
+
expect(lastDecision[1]).toBe('deny');
|
|
3180
|
+
expect(typeof lastDecision[2]).toBe('string');
|
|
3181
|
+
expect((lastDecision[2] as string)).toContain('identity could not be verified');
|
|
3182
|
+
|
|
3183
|
+
// No separate deterministic denial notice should be emitted here.
|
|
2936
3184
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
2937
|
-
(call) =>
|
|
2938
|
-
|
|
2939
|
-
const text = (call[1] as { text?: string }).text ?? '';
|
|
2940
|
-
return text.includes('requires guardian approval') &&
|
|
2941
|
-
(text.includes('identity could not be determined') || text.includes('no guardian has been set up'));
|
|
2942
|
-
},
|
|
3185
|
+
(call) => typeof call[1] === 'object'
|
|
3186
|
+
&& ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
|
|
2943
3187
|
);
|
|
2944
|
-
expect(denialCalls.length).
|
|
3188
|
+
expect(denialCalls.length).toBe(0);
|
|
2945
3189
|
|
|
2946
3190
|
// Auto-deny path should never prompt for approval
|
|
2947
3191
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -2950,25 +3194,40 @@ describe('guardian enforcement independence from approval flag', () => {
|
|
|
2950
3194
|
approvalSpy.mockRestore();
|
|
2951
3195
|
});
|
|
2952
3196
|
|
|
2953
|
-
test('missing senderExternalUserId without guardian binding
|
|
2954
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
3197
|
+
test('missing senderExternalUserId without guardian binding fails closed', async () => {
|
|
2955
3198
|
|
|
2956
|
-
// No guardian binding exists
|
|
2957
|
-
|
|
3199
|
+
// No guardian binding exists, but identity is missing — treat sender as
|
|
3200
|
+
// unverified_channel and auto-deny sensitive actions.
|
|
3201
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-noid-nobinding', terminalStatus: 'failed' });
|
|
2958
3202
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3203
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2959
3204
|
|
|
2960
3205
|
const req = makeInboundRequest({
|
|
2961
|
-
content: '
|
|
3206
|
+
content: 'do something dangerous',
|
|
2962
3207
|
senderExternalUserId: undefined,
|
|
2963
3208
|
});
|
|
2964
3209
|
|
|
2965
3210
|
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2966
3211
|
expect(res.status).toBe(200);
|
|
2967
3212
|
|
|
2968
|
-
|
|
2969
|
-
|
|
3213
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3214
|
+
|
|
3215
|
+
const submitCalls = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls;
|
|
3216
|
+
expect(submitCalls.length).toBeGreaterThanOrEqual(1);
|
|
3217
|
+
const lastDecision = submitCalls[submitCalls.length - 1];
|
|
3218
|
+
expect(lastDecision[1]).toBe('deny');
|
|
3219
|
+
expect(typeof lastDecision[2]).toBe('string');
|
|
3220
|
+
expect((lastDecision[2] as string)).toContain('identity could not be verified');
|
|
3221
|
+
|
|
3222
|
+
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3223
|
+
(call) => typeof call[1] === 'object'
|
|
3224
|
+
&& ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
|
|
3225
|
+
);
|
|
3226
|
+
expect(denialCalls.length).toBe(0);
|
|
3227
|
+
expect(approvalSpy).not.toHaveBeenCalled();
|
|
2970
3228
|
|
|
2971
3229
|
deliverSpy.mockRestore();
|
|
3230
|
+
approvalSpy.mockRestore();
|
|
2972
3231
|
});
|
|
2973
3232
|
});
|
|
2974
3233
|
|
|
@@ -3119,7 +3378,6 @@ describe('handleChannelInbound gatewayOriginSecret integration', () => {
|
|
|
3119
3378
|
|
|
3120
3379
|
describe('unknown actor identity — forceStrictSideEffects', () => {
|
|
3121
3380
|
beforeEach(() => {
|
|
3122
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
3123
3381
|
});
|
|
3124
3382
|
|
|
3125
3383
|
test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {
|