@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
|
@@ -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 {
|
|
@@ -54,14 +56,13 @@ import {
|
|
|
54
56
|
createApprovalRequest,
|
|
55
57
|
getPendingApprovalForRun,
|
|
56
58
|
getUnresolvedApprovalForRun,
|
|
57
|
-
getExpiredPendingApprovals,
|
|
58
|
-
updateApprovalDecision,
|
|
59
59
|
} from '../memory/channel-guardian-store.js';
|
|
60
60
|
import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
|
|
61
61
|
import {
|
|
62
62
|
handleChannelInbound,
|
|
63
|
-
isChannelApprovalsEnabled,
|
|
64
63
|
sweepExpiredGuardianApprovals,
|
|
64
|
+
verifyGatewayOrigin,
|
|
65
|
+
_setTestPollMaxWait,
|
|
65
66
|
} from '../runtime/routes/channel-routes.js';
|
|
66
67
|
import * as gatewayClient from '../runtime/gateway-client.js';
|
|
67
68
|
|
|
@@ -94,10 +95,12 @@ 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');
|
|
100
102
|
db.run('DELETE FROM conversations');
|
|
103
|
+
channelDeliveryStore.resetAllRunDeliveryClaims();
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
const sampleConfirmation: PendingConfirmation = {
|
|
@@ -132,10 +135,15 @@ function makeMockOrchestrator(
|
|
|
132
135
|
} as unknown as RunOrchestrator;
|
|
133
136
|
}
|
|
134
137
|
|
|
138
|
+
/** Default bearer token used by tests. Include the X-Gateway-Origin header
|
|
139
|
+
* so that verifyGatewayOrigin does not reject the request. */
|
|
140
|
+
const TEST_BEARER_TOKEN = 'token';
|
|
141
|
+
|
|
135
142
|
function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
136
143
|
const body = {
|
|
137
144
|
sourceChannel: 'telegram',
|
|
138
145
|
externalChatId: 'chat-123',
|
|
146
|
+
senderExternalUserId: 'telegram-user-default',
|
|
139
147
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
140
148
|
content: 'hello',
|
|
141
149
|
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
@@ -143,60 +151,22 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
|
143
151
|
};
|
|
144
152
|
return new Request('http://localhost/channels/inbound', {
|
|
145
153
|
method: 'POST',
|
|
146
|
-
headers: {
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
157
|
+
},
|
|
147
158
|
body: JSON.stringify(body),
|
|
148
159
|
});
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
const noopProcessMessage = mock(async () => ({ messageId: 'msg-1' }));
|
|
152
163
|
|
|
153
|
-
// ---------------------------------------------------------------------------
|
|
154
|
-
// Set up / tear down feature flag for each test
|
|
155
|
-
// ---------------------------------------------------------------------------
|
|
156
|
-
|
|
157
|
-
let originalEnv: string | undefined;
|
|
158
|
-
|
|
159
164
|
beforeEach(() => {
|
|
160
165
|
resetTables();
|
|
161
|
-
originalEnv = process.env.CHANNEL_APPROVALS_ENABLED;
|
|
162
166
|
noopProcessMessage.mockClear();
|
|
163
167
|
});
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
if (originalEnv === undefined) {
|
|
167
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
168
|
-
} else {
|
|
169
|
-
process.env.CHANNEL_APPROVALS_ENABLED = originalEnv;
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
174
|
-
// 1. Feature flag gating
|
|
175
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
176
|
-
|
|
177
|
-
describe('isChannelApprovalsEnabled', () => {
|
|
178
|
-
test('returns false when env var is not set', () => {
|
|
179
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
180
|
-
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test('returns false when env var is "false"', () => {
|
|
184
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'false';
|
|
185
|
-
expect(isChannelApprovalsEnabled()).toBe(false);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
test('returns true when env var is "true"', () => {
|
|
189
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
190
|
-
expect(isChannelApprovalsEnabled()).toBe(true);
|
|
191
|
-
});
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
describe('feature flag disabled → normal flow', () => {
|
|
195
|
-
beforeEach(() => {
|
|
196
|
-
delete process.env.CHANNEL_APPROVALS_ENABLED;
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
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 () => {
|
|
200
170
|
ensureConversation('conv-1');
|
|
201
171
|
const run = createRun('conv-1');
|
|
202
172
|
setRunConfirmation(run.id, sampleConfirmation);
|
|
@@ -210,9 +180,10 @@ describe('feature flag disabled → normal flow', () => {
|
|
|
210
180
|
const res = await handleChannelInbound(req, noopProcessMessage, undefined, orchestrator);
|
|
211
181
|
const body = await res.json() as Record<string, unknown>;
|
|
212
182
|
|
|
213
|
-
//
|
|
183
|
+
// Callback payloads without a matching pending approval are treated as
|
|
184
|
+
// stale and ignored.
|
|
214
185
|
expect(body.accepted).toBe(true);
|
|
215
|
-
expect(body.approval).
|
|
186
|
+
expect(body.approval).toBe('stale_ignored');
|
|
216
187
|
});
|
|
217
188
|
});
|
|
218
189
|
|
|
@@ -222,7 +193,12 @@ describe('feature flag disabled → normal flow', () => {
|
|
|
222
193
|
|
|
223
194
|
describe('inbound callback metadata triggers decision handling', () => {
|
|
224
195
|
beforeEach(() => {
|
|
225
|
-
|
|
196
|
+
createBinding({
|
|
197
|
+
assistantId: 'self',
|
|
198
|
+
channel: 'telegram',
|
|
199
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
200
|
+
guardianDeliveryChatId: 'chat-123',
|
|
201
|
+
});
|
|
226
202
|
});
|
|
227
203
|
|
|
228
204
|
test('callback data "apr:<runId>:approve_once" is parsed and applied', async () => {
|
|
@@ -314,7 +290,12 @@ describe('inbound callback metadata triggers decision handling', () => {
|
|
|
314
290
|
|
|
315
291
|
describe('inbound text matching approval phrases triggers decision handling', () => {
|
|
316
292
|
beforeEach(() => {
|
|
317
|
-
|
|
293
|
+
createBinding({
|
|
294
|
+
assistantId: 'self',
|
|
295
|
+
channel: 'telegram',
|
|
296
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
297
|
+
guardianDeliveryChatId: 'chat-123',
|
|
298
|
+
});
|
|
318
299
|
});
|
|
319
300
|
|
|
320
301
|
test('text "approve" triggers approve_once decision', async () => {
|
|
@@ -379,7 +360,12 @@ describe('inbound text matching approval phrases triggers decision handling', ()
|
|
|
379
360
|
|
|
380
361
|
describe('non-decision messages during pending approval trigger reminder', () => {
|
|
381
362
|
beforeEach(() => {
|
|
382
|
-
|
|
363
|
+
createBinding({
|
|
364
|
+
assistantId: 'self',
|
|
365
|
+
channel: 'telegram',
|
|
366
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
367
|
+
guardianDeliveryChatId: 'chat-123',
|
|
368
|
+
});
|
|
383
369
|
});
|
|
384
370
|
|
|
385
371
|
test('sends a reminder prompt when message is not a decision', async () => {
|
|
@@ -427,7 +413,6 @@ describe('non-decision messages during pending approval trigger reminder', () =>
|
|
|
427
413
|
|
|
428
414
|
describe('messages without pending approval proceed normally', () => {
|
|
429
415
|
beforeEach(() => {
|
|
430
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
431
416
|
});
|
|
432
417
|
|
|
433
418
|
test('proceeds to normal processing when no pending approval exists', async () => {
|
|
@@ -471,7 +456,6 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
471
456
|
});
|
|
472
457
|
|
|
473
458
|
test('allows empty content when callbackData is present', async () => {
|
|
474
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
475
459
|
const orchestrator = makeMockOrchestrator();
|
|
476
460
|
|
|
477
461
|
// Establish the conversation first
|
|
@@ -504,7 +488,6 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
504
488
|
});
|
|
505
489
|
|
|
506
490
|
test('allows undefined content when callbackData is present', async () => {
|
|
507
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
508
491
|
const orchestrator = makeMockOrchestrator();
|
|
509
492
|
|
|
510
493
|
// Establish the conversation first
|
|
@@ -531,11 +514,14 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
531
514
|
};
|
|
532
515
|
const req = new Request('http://localhost/channels/inbound', {
|
|
533
516
|
method: 'POST',
|
|
534
|
-
headers: {
|
|
517
|
+
headers: {
|
|
518
|
+
'Content-Type': 'application/json',
|
|
519
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
520
|
+
},
|
|
535
521
|
body: JSON.stringify(body),
|
|
536
522
|
});
|
|
537
523
|
|
|
538
|
-
const res = await handleChannelInbound(req, noopProcessMessage,
|
|
524
|
+
const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN, orchestrator);
|
|
539
525
|
expect(res.status).toBe(200);
|
|
540
526
|
const resBody = await res.json() as Record<string, unknown>;
|
|
541
527
|
expect(resBody.accepted).toBe(true);
|
|
@@ -550,7 +536,12 @@ describe('empty content with callbackData bypasses validation', () => {
|
|
|
550
536
|
|
|
551
537
|
describe('callback run ID validation', () => {
|
|
552
538
|
beforeEach(() => {
|
|
553
|
-
|
|
539
|
+
createBinding({
|
|
540
|
+
assistantId: 'self',
|
|
541
|
+
channel: 'telegram',
|
|
542
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
543
|
+
guardianDeliveryChatId: 'chat-123',
|
|
544
|
+
});
|
|
554
545
|
});
|
|
555
546
|
|
|
556
547
|
test('ignores stale callback when run ID does not match pending run', async () => {
|
|
@@ -658,7 +649,6 @@ describe('callback run ID validation', () => {
|
|
|
658
649
|
|
|
659
650
|
describe('linkMessage in approval-aware processing path', () => {
|
|
660
651
|
beforeEach(() => {
|
|
661
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
662
652
|
});
|
|
663
653
|
|
|
664
654
|
test('linkMessage is called when run has a messageId and reaches terminal state', async () => {
|
|
@@ -715,7 +705,6 @@ describe('linkMessage in approval-aware processing path', () => {
|
|
|
715
705
|
|
|
716
706
|
describe('terminal state check before markProcessed', () => {
|
|
717
707
|
beforeEach(() => {
|
|
718
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
719
708
|
});
|
|
720
709
|
|
|
721
710
|
test('records processing failure when run disappears (non-approval non-terminal state)', async () => {
|
|
@@ -854,7 +843,12 @@ describe('terminal state check before markProcessed', () => {
|
|
|
854
843
|
|
|
855
844
|
describe('no immediate reply after approval decision', () => {
|
|
856
845
|
beforeEach(() => {
|
|
857
|
-
|
|
846
|
+
createBinding({
|
|
847
|
+
assistantId: 'self',
|
|
848
|
+
channel: 'telegram',
|
|
849
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
850
|
+
guardianDeliveryChatId: 'chat-123',
|
|
851
|
+
});
|
|
858
852
|
});
|
|
859
853
|
|
|
860
854
|
test('deliverChannelReply is NOT called from interception after decision is applied', async () => {
|
|
@@ -932,7 +926,6 @@ describe('no immediate reply after approval decision', () => {
|
|
|
932
926
|
|
|
933
927
|
describe('stale callback handling', () => {
|
|
934
928
|
beforeEach(() => {
|
|
935
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
936
929
|
});
|
|
937
930
|
|
|
938
931
|
test('callback with no pending approval returns stale_ignored and does not start a run', async () => {
|
|
@@ -996,7 +989,6 @@ describe('stale callback handling', () => {
|
|
|
996
989
|
|
|
997
990
|
describe('poll timeout handling by run state', () => {
|
|
998
991
|
beforeEach(() => {
|
|
999
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1000
992
|
});
|
|
1001
993
|
|
|
1002
994
|
test('records processing failure when run disappears (getRun returns null) before terminal state', async () => {
|
|
@@ -1048,6 +1040,10 @@ describe('poll timeout handling by run state', () => {
|
|
|
1048
1040
|
});
|
|
1049
1041
|
|
|
1050
1042
|
test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
|
|
1043
|
+
// Use a short poll timeout so the test can exercise the timeout path
|
|
1044
|
+
// without waiting 5 minutes. Must exceed one poll interval (500ms).
|
|
1045
|
+
_setTestPollMaxWait(700);
|
|
1046
|
+
|
|
1051
1047
|
const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
|
|
1052
1048
|
const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
|
|
1053
1049
|
const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
|
|
@@ -1068,12 +1064,18 @@ describe('poll timeout handling by run state', () => {
|
|
|
1068
1064
|
updatedAt: Date.now(),
|
|
1069
1065
|
};
|
|
1070
1066
|
|
|
1071
|
-
// getRun returns
|
|
1072
|
-
//
|
|
1073
|
-
//
|
|
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;
|
|
1074
1072
|
const orchestrator = {
|
|
1075
1073
|
submitDecision: mock(() => 'applied' as const),
|
|
1076
|
-
getRun: mock(() =>
|
|
1074
|
+
getRun: mock(() => {
|
|
1075
|
+
getRunCalls++;
|
|
1076
|
+
if (getRunCalls <= 1) return null;
|
|
1077
|
+
return { ...mockRun, status: 'needs_confirmation' as const };
|
|
1078
|
+
}),
|
|
1077
1079
|
startRun: mock(async () => mockRun),
|
|
1078
1080
|
} as unknown as RunOrchestrator;
|
|
1079
1081
|
|
|
@@ -1094,6 +1096,126 @@ describe('poll timeout handling by run state', () => {
|
|
|
1094
1096
|
markSpy.mockRestore();
|
|
1095
1097
|
failureSpy.mockRestore();
|
|
1096
1098
|
deliverSpy.mockRestore();
|
|
1099
|
+
_setTestPollMaxWait(null);
|
|
1100
|
+
});
|
|
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);
|
|
1097
1219
|
});
|
|
1098
1220
|
|
|
1099
1221
|
test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
|
|
@@ -1149,7 +1271,6 @@ describe('poll timeout handling by run state', () => {
|
|
|
1149
1271
|
|
|
1150
1272
|
describe('post-decision delivery after poll timeout', () => {
|
|
1151
1273
|
beforeEach(() => {
|
|
1152
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1153
1274
|
});
|
|
1154
1275
|
|
|
1155
1276
|
test('delivers reply via callback after a late approval decision', async () => {
|
|
@@ -1263,7 +1384,6 @@ describe('post-decision delivery after poll timeout', () => {
|
|
|
1263
1384
|
|
|
1264
1385
|
describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
1265
1386
|
beforeEach(() => {
|
|
1266
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1267
1387
|
});
|
|
1268
1388
|
|
|
1269
1389
|
test('startRun is called with sourceChannel from inbound event', async () => {
|
|
@@ -1321,13 +1441,19 @@ describe('sourceChannel passed to orchestrator.startRun', () => {
|
|
|
1321
1441
|
|
|
1322
1442
|
describe('SMS channel approval decisions', () => {
|
|
1323
1443
|
beforeEach(() => {
|
|
1324
|
-
|
|
1444
|
+
createBinding({
|
|
1445
|
+
assistantId: 'self',
|
|
1446
|
+
channel: 'sms',
|
|
1447
|
+
guardianExternalUserId: 'sms-user-default',
|
|
1448
|
+
guardianDeliveryChatId: 'sms-chat-123',
|
|
1449
|
+
});
|
|
1325
1450
|
});
|
|
1326
1451
|
|
|
1327
1452
|
function makeSmsInboundRequest(overrides: Record<string, unknown> = {}): Request {
|
|
1328
1453
|
const body = {
|
|
1329
1454
|
sourceChannel: 'sms',
|
|
1330
1455
|
externalChatId: 'sms-chat-123',
|
|
1456
|
+
senderExternalUserId: 'sms-user-default',
|
|
1331
1457
|
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
1332
1458
|
content: 'hello',
|
|
1333
1459
|
replyCallbackUrl: 'https://gateway.test/deliver',
|
|
@@ -1335,7 +1461,10 @@ describe('SMS channel approval decisions', () => {
|
|
|
1335
1461
|
};
|
|
1336
1462
|
return new Request('http://localhost/channels/inbound', {
|
|
1337
1463
|
method: 'POST',
|
|
1338
|
-
headers: {
|
|
1464
|
+
headers: {
|
|
1465
|
+
'Content-Type': 'application/json',
|
|
1466
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1467
|
+
},
|
|
1339
1468
|
body: JSON.stringify(body),
|
|
1340
1469
|
});
|
|
1341
1470
|
}
|
|
@@ -1481,7 +1610,10 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1481
1610
|
|
|
1482
1611
|
const req = new Request('http://localhost/channels/inbound', {
|
|
1483
1612
|
method: 'POST',
|
|
1484
|
-
headers: {
|
|
1613
|
+
headers: {
|
|
1614
|
+
'Content-Type': 'application/json',
|
|
1615
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1616
|
+
},
|
|
1485
1617
|
body: JSON.stringify({
|
|
1486
1618
|
sourceChannel: 'sms',
|
|
1487
1619
|
externalChatId: 'sms-chat-verify',
|
|
@@ -1492,7 +1624,7 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1492
1624
|
}),
|
|
1493
1625
|
});
|
|
1494
1626
|
|
|
1495
|
-
const res = await handleChannelInbound(req, noopProcessMessage,
|
|
1627
|
+
const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN);
|
|
1496
1628
|
const body = await res.json() as Record<string, unknown>;
|
|
1497
1629
|
|
|
1498
1630
|
expect(body.accepted).toBe(true);
|
|
@@ -1513,7 +1645,10 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1513
1645
|
|
|
1514
1646
|
const req = new Request('http://localhost/channels/inbound', {
|
|
1515
1647
|
method: 'POST',
|
|
1516
|
-
headers: {
|
|
1648
|
+
headers: {
|
|
1649
|
+
'Content-Type': 'application/json',
|
|
1650
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1651
|
+
},
|
|
1517
1652
|
body: JSON.stringify({
|
|
1518
1653
|
sourceChannel: 'sms',
|
|
1519
1654
|
externalChatId: 'sms-chat-verify-fail',
|
|
@@ -1524,7 +1659,7 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1524
1659
|
}),
|
|
1525
1660
|
});
|
|
1526
1661
|
|
|
1527
|
-
const res = await handleChannelInbound(req, noopProcessMessage,
|
|
1662
|
+
const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN);
|
|
1528
1663
|
const body = await res.json() as Record<string, unknown>;
|
|
1529
1664
|
|
|
1530
1665
|
expect(body.accepted).toBe(true);
|
|
@@ -1545,7 +1680,6 @@ describe('SMS guardian verify intercept', () => {
|
|
|
1545
1680
|
|
|
1546
1681
|
describe('SMS non-guardian actor gating', () => {
|
|
1547
1682
|
beforeEach(() => {
|
|
1548
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1549
1683
|
});
|
|
1550
1684
|
|
|
1551
1685
|
test('non-guardian SMS actor gets stricter controls when guardian binding exists', async () => {
|
|
@@ -1585,7 +1719,10 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1585
1719
|
// Send message from a NON-guardian sms user
|
|
1586
1720
|
const req = new Request('http://localhost/channels/inbound', {
|
|
1587
1721
|
method: 'POST',
|
|
1588
|
-
headers: {
|
|
1722
|
+
headers: {
|
|
1723
|
+
'Content-Type': 'application/json',
|
|
1724
|
+
'X-Gateway-Origin': TEST_BEARER_TOKEN,
|
|
1725
|
+
},
|
|
1589
1726
|
body: JSON.stringify({
|
|
1590
1727
|
sourceChannel: 'sms',
|
|
1591
1728
|
externalChatId: 'sms-other-chat',
|
|
@@ -1596,7 +1733,7 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1596
1733
|
}),
|
|
1597
1734
|
});
|
|
1598
1735
|
|
|
1599
|
-
await handleChannelInbound(req, noopProcessMessage,
|
|
1736
|
+
await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN, orchestrator);
|
|
1600
1737
|
|
|
1601
1738
|
// Wait for the background async to fire
|
|
1602
1739
|
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
@@ -1616,7 +1753,18 @@ describe('SMS non-guardian actor gating', () => {
|
|
|
1616
1753
|
|
|
1617
1754
|
describe('plain-text fallback surfacing for non-rich channels', () => {
|
|
1618
1755
|
beforeEach(() => {
|
|
1619
|
-
|
|
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
|
+
});
|
|
1620
1768
|
});
|
|
1621
1769
|
|
|
1622
1770
|
test('reminder prompt includes plainTextFallback for non-rich channel (http-api)', async () => {
|
|
@@ -1772,10 +1920,9 @@ function makeSensitiveOrchestrator(opts: {
|
|
|
1772
1920
|
|
|
1773
1921
|
describe('fail-closed guardian gate — unverified channel', () => {
|
|
1774
1922
|
beforeEach(() => {
|
|
1775
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1776
1923
|
});
|
|
1777
1924
|
|
|
1778
|
-
test('no binding + sensitive action → auto-deny
|
|
1925
|
+
test('no binding + sensitive action → auto-deny with contextual assistant guidance', async () => {
|
|
1779
1926
|
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
1780
1927
|
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
1781
1928
|
|
|
@@ -1795,11 +1942,15 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1795
1942
|
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
1796
1943
|
expect(decisionArgs[1]).toBe('deny');
|
|
1797
1944
|
|
|
1798
|
-
// The
|
|
1799
|
-
|
|
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(
|
|
1800
1951
|
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
|
|
1801
1952
|
);
|
|
1802
|
-
expect(
|
|
1953
|
+
expect(deterministicNoticeCalls.length).toBe(0);
|
|
1803
1954
|
|
|
1804
1955
|
// No approval prompt should have been sent to a guardian (none exists)
|
|
1805
1956
|
expect(approvalSpy).not.toHaveBeenCalled();
|
|
@@ -1888,11 +2039,19 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1888
2039
|
expect(body.accepted).toBe(true);
|
|
1889
2040
|
expect(body.approval).toBe('decision_applied');
|
|
1890
2041
|
|
|
1891
|
-
// 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.
|
|
1892
2051
|
const denialCalls = deliverSpy.mock.calls.filter(
|
|
1893
2052
|
(call) => typeof call[1] === 'object' && (call[1] as { text?: string }).text?.includes('no guardian has been set up'),
|
|
1894
2053
|
);
|
|
1895
|
-
expect(denialCalls.length).
|
|
2054
|
+
expect(denialCalls.length).toBe(0);
|
|
1896
2055
|
|
|
1897
2056
|
deliverSpy.mockRestore();
|
|
1898
2057
|
});
|
|
@@ -1904,7 +2063,6 @@ describe('fail-closed guardian gate — unverified channel', () => {
|
|
|
1904
2063
|
|
|
1905
2064
|
describe('guardian-with-binding path regression', () => {
|
|
1906
2065
|
beforeEach(() => {
|
|
1907
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1908
2066
|
});
|
|
1909
2067
|
|
|
1910
2068
|
test('non-guardian with binding routes approval to guardian chat', async () => {
|
|
@@ -1976,6 +2134,53 @@ describe('guardian-with-binding path regression', () => {
|
|
|
1976
2134
|
deliverSpy.mockRestore();
|
|
1977
2135
|
approvalSpy.mockRestore();
|
|
1978
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
|
+
});
|
|
1979
2184
|
});
|
|
1980
2185
|
|
|
1981
2186
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1984,7 +2189,6 @@ describe('guardian-with-binding path regression', () => {
|
|
|
1984
2189
|
|
|
1985
2190
|
describe('guardian delivery failure → denial', () => {
|
|
1986
2191
|
beforeEach(() => {
|
|
1987
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
1988
2192
|
});
|
|
1989
2193
|
|
|
1990
2194
|
test('delivery failure denies run and notifies requester', async () => {
|
|
@@ -2074,13 +2278,57 @@ describe('guardian delivery failure → denial', () => {
|
|
|
2074
2278
|
});
|
|
2075
2279
|
});
|
|
2076
2280
|
|
|
2281
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2282
|
+
// 20b. Standard approval prompt delivery failure → auto-deny (WS-B)
|
|
2283
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2284
|
+
|
|
2285
|
+
describe('standard approval prompt delivery failure → auto-deny', () => {
|
|
2286
|
+
beforeEach(() => {
|
|
2287
|
+
});
|
|
2288
|
+
|
|
2289
|
+
test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
|
|
2290
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2291
|
+
// Make the approval prompt delivery fail for the standard (self-approval) path
|
|
2292
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
|
|
2293
|
+
new Error('Network error: approval prompt unreachable'),
|
|
2294
|
+
);
|
|
2295
|
+
|
|
2296
|
+
// No guardian binding — sender is a guardian (default role), so the
|
|
2297
|
+
// standard self-approval path is used.
|
|
2298
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-std-fail', terminalStatus: 'failed' });
|
|
2299
|
+
|
|
2300
|
+
const req = makeInboundRequest({
|
|
2301
|
+
content: 'do something dangerous',
|
|
2302
|
+
senderExternalUserId: 'guardian-std-user',
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
// Set up a guardian binding so the sender is recognized as guardian
|
|
2306
|
+
createBinding({
|
|
2307
|
+
assistantId: 'self',
|
|
2308
|
+
channel: 'telegram',
|
|
2309
|
+
guardianExternalUserId: 'guardian-std-user',
|
|
2310
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
2314
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2315
|
+
|
|
2316
|
+
// The run should have been auto-denied because the prompt could not be delivered
|
|
2317
|
+
expect(orchestrator.submitDecision).toHaveBeenCalled();
|
|
2318
|
+
const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
|
|
2319
|
+
expect(decisionArgs[1]).toBe('deny');
|
|
2320
|
+
|
|
2321
|
+
deliverSpy.mockRestore();
|
|
2322
|
+
approvalSpy.mockRestore();
|
|
2323
|
+
});
|
|
2324
|
+
});
|
|
2325
|
+
|
|
2077
2326
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2078
2327
|
// 21. Guardian decision scoping — callback for older run resolves correctly
|
|
2079
2328
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
2080
2329
|
|
|
2081
2330
|
describe('guardian decision scoping — multiple pending approvals', () => {
|
|
2082
2331
|
beforeEach(() => {
|
|
2083
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2084
2332
|
});
|
|
2085
2333
|
|
|
2086
2334
|
test('callback for older run resolves to the correct approval request', async () => {
|
|
@@ -2167,7 +2415,6 @@ describe('guardian decision scoping — multiple pending approvals', () => {
|
|
|
2167
2415
|
|
|
2168
2416
|
describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
2169
2417
|
beforeEach(() => {
|
|
2170
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2171
2418
|
});
|
|
2172
2419
|
|
|
2173
2420
|
test('does not apply plain-text decision to wrong run when multiple pending', async () => {
|
|
@@ -2254,7 +2501,6 @@ describe('ambiguous plain-text decision with multiple pending requests', () => {
|
|
|
2254
2501
|
|
|
2255
2502
|
describe('expired guardian approval auto-denies via sweep', () => {
|
|
2256
2503
|
beforeEach(() => {
|
|
2257
|
-
process.env.CHANNEL_APPROVALS_ENABLED = 'true';
|
|
2258
2504
|
});
|
|
2259
2505
|
|
|
2260
2506
|
test('sweepExpiredGuardianApprovals auto-denies and notifies both parties', async () => {
|
|
@@ -2360,3 +2606,931 @@ describe('expired guardian approval auto-denies via sweep', () => {
|
|
|
2360
2606
|
deliverSpy.mockRestore();
|
|
2361
2607
|
});
|
|
2362
2608
|
});
|
|
2609
|
+
|
|
2610
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2611
|
+
// 24. Deliver-once idempotency guard
|
|
2612
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2613
|
+
|
|
2614
|
+
describe('deliver-once idempotency guard', () => {
|
|
2615
|
+
test('claimRunDelivery returns true on first call, false on subsequent calls', () => {
|
|
2616
|
+
const runId = 'run-idem-unit';
|
|
2617
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
2618
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
|
|
2619
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
|
|
2620
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
2621
|
+
});
|
|
2622
|
+
|
|
2623
|
+
test('different run IDs are independent', () => {
|
|
2624
|
+
expect(channelDeliveryStore.claimRunDelivery('run-a')).toBe(true);
|
|
2625
|
+
expect(channelDeliveryStore.claimRunDelivery('run-b')).toBe(true);
|
|
2626
|
+
expect(channelDeliveryStore.claimRunDelivery('run-a')).toBe(false);
|
|
2627
|
+
expect(channelDeliveryStore.claimRunDelivery('run-b')).toBe(false);
|
|
2628
|
+
channelDeliveryStore.resetRunDeliveryClaim('run-a');
|
|
2629
|
+
channelDeliveryStore.resetRunDeliveryClaim('run-b');
|
|
2630
|
+
});
|
|
2631
|
+
|
|
2632
|
+
test('resetRunDeliveryClaim allows re-claim', () => {
|
|
2633
|
+
const runId = 'run-idem-reset';
|
|
2634
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
2635
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
2636
|
+
expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
|
|
2637
|
+
channelDeliveryStore.resetRunDeliveryClaim(runId);
|
|
2638
|
+
});
|
|
2639
|
+
});
|
|
2640
|
+
|
|
2641
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2642
|
+
// 25. Final reply idempotency — main poll wins vs post-decision poll wins
|
|
2643
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2644
|
+
|
|
2645
|
+
describe('final reply idempotency — no duplicate delivery', () => {
|
|
2646
|
+
beforeEach(() => {
|
|
2647
|
+
createBinding({
|
|
2648
|
+
assistantId: 'self',
|
|
2649
|
+
channel: 'telegram',
|
|
2650
|
+
guardianExternalUserId: 'telegram-user-default',
|
|
2651
|
+
guardianDeliveryChatId: 'chat-123',
|
|
2652
|
+
});
|
|
2653
|
+
});
|
|
2654
|
+
|
|
2655
|
+
test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
|
|
2656
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2657
|
+
|
|
2658
|
+
// Establish the conversation
|
|
2659
|
+
const initReq = makeInboundRequest({ content: 'init' });
|
|
2660
|
+
const orchestrator = makeMockOrchestrator();
|
|
2661
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
2662
|
+
|
|
2663
|
+
const db = getDb();
|
|
2664
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
2665
|
+
const conversationId = events[0]?.conversation_id;
|
|
2666
|
+
ensureConversation(conversationId!);
|
|
2667
|
+
|
|
2668
|
+
// Create a pending run and add an assistant message for delivery
|
|
2669
|
+
const run = createRun(conversationId!);
|
|
2670
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2671
|
+
conversationStore.addMessage(conversationId!, 'assistant', 'Main poll result.');
|
|
2672
|
+
|
|
2673
|
+
// Orchestrator: first getRun returns needs_confirmation (to trigger
|
|
2674
|
+
// approval prompt delivery in the poll), subsequent calls return
|
|
2675
|
+
// completed so the main poll can deliver the reply.
|
|
2676
|
+
let getRunCount = 0;
|
|
2677
|
+
const racingOrchestrator = {
|
|
2678
|
+
submitDecision: mock(() => 'applied' as const),
|
|
2679
|
+
getRun: mock(() => {
|
|
2680
|
+
getRunCount++;
|
|
2681
|
+
if (getRunCount <= 1) {
|
|
2682
|
+
return {
|
|
2683
|
+
id: run.id,
|
|
2684
|
+
conversationId: conversationId!,
|
|
2685
|
+
messageId: null,
|
|
2686
|
+
status: 'needs_confirmation' as const,
|
|
2687
|
+
pendingConfirmation: sampleConfirmation,
|
|
2688
|
+
pendingSecret: null,
|
|
2689
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2690
|
+
error: null,
|
|
2691
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2692
|
+
};
|
|
2693
|
+
}
|
|
2694
|
+
return {
|
|
2695
|
+
id: run.id,
|
|
2696
|
+
conversationId: conversationId!,
|
|
2697
|
+
messageId: null,
|
|
2698
|
+
status: 'completed' as const,
|
|
2699
|
+
pendingConfirmation: null,
|
|
2700
|
+
pendingSecret: null,
|
|
2701
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2702
|
+
error: null,
|
|
2703
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2704
|
+
};
|
|
2705
|
+
}),
|
|
2706
|
+
startRun: mock(async () => ({
|
|
2707
|
+
id: run.id,
|
|
2708
|
+
conversationId: conversationId!,
|
|
2709
|
+
messageId: null,
|
|
2710
|
+
status: 'running' as const,
|
|
2711
|
+
pendingConfirmation: null,
|
|
2712
|
+
pendingSecret: null,
|
|
2713
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2714
|
+
error: null,
|
|
2715
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2716
|
+
})),
|
|
2717
|
+
} as unknown as RunOrchestrator;
|
|
2718
|
+
|
|
2719
|
+
deliverSpy.mockClear();
|
|
2720
|
+
|
|
2721
|
+
// Send a message that triggers the approval path, then send a decision
|
|
2722
|
+
// to trigger the post-decision poll. Both pollers should compete for delivery.
|
|
2723
|
+
const msgReq = makeInboundRequest({ content: 'do something' });
|
|
2724
|
+
await handleChannelInbound(msgReq, noopProcessMessage, 'token', racingOrchestrator);
|
|
2725
|
+
|
|
2726
|
+
// Send the decision to start the post-decision delivery poll
|
|
2727
|
+
const decisionReq = makeInboundRequest({
|
|
2728
|
+
content: '',
|
|
2729
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
2730
|
+
});
|
|
2731
|
+
await handleChannelInbound(decisionReq, noopProcessMessage, 'token', racingOrchestrator);
|
|
2732
|
+
|
|
2733
|
+
// Wait for both pollers to finish
|
|
2734
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
2735
|
+
|
|
2736
|
+
// Count deliverChannelReply calls that carry the assistant reply text.
|
|
2737
|
+
// Approval-related notifications (e.g. "has been sent to the guardian")
|
|
2738
|
+
// are separate from the final reply. The final reply call is the one
|
|
2739
|
+
// that delivers the actual conversation content.
|
|
2740
|
+
const replyDeliveryCalls = deliverSpy.mock.calls.filter(
|
|
2741
|
+
(call) => typeof call[1] === 'object' &&
|
|
2742
|
+
(call[1] as { text?: string }).text === 'Main poll result.',
|
|
2743
|
+
);
|
|
2744
|
+
|
|
2745
|
+
// The guard should ensure at most one delivery of the final reply
|
|
2746
|
+
expect(replyDeliveryCalls.length).toBeLessThanOrEqual(1);
|
|
2747
|
+
|
|
2748
|
+
deliverSpy.mockRestore();
|
|
2749
|
+
});
|
|
2750
|
+
|
|
2751
|
+
test('post-decision poll wins: delivers exactly once when main poll already exited', async () => {
|
|
2752
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2753
|
+
|
|
2754
|
+
// Establish the conversation
|
|
2755
|
+
const initReq = makeInboundRequest({ content: 'init-late' });
|
|
2756
|
+
const orchestrator = makeMockOrchestrator();
|
|
2757
|
+
await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
|
|
2758
|
+
|
|
2759
|
+
const db = getDb();
|
|
2760
|
+
const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
|
|
2761
|
+
const conversationId = events[events.length - 1]?.conversation_id;
|
|
2762
|
+
ensureConversation(conversationId!);
|
|
2763
|
+
|
|
2764
|
+
// Create a pending run
|
|
2765
|
+
const run = createRun(conversationId!);
|
|
2766
|
+
setRunConfirmation(run.id, sampleConfirmation);
|
|
2767
|
+
conversationStore.addMessage(conversationId!, 'assistant', 'Post-decision result.');
|
|
2768
|
+
|
|
2769
|
+
// Orchestrator: getRun always returns needs_confirmation for the main poll
|
|
2770
|
+
// (so the main poll times out without delivering), then returns completed
|
|
2771
|
+
// for the post-decision poll. We use a separate call counter per context.
|
|
2772
|
+
let mainPollExited = false;
|
|
2773
|
+
let postDecisionGetRunCount = 0;
|
|
2774
|
+
const lateOrchestrator = {
|
|
2775
|
+
submitDecision: mock(() => 'applied' as const),
|
|
2776
|
+
getRun: mock(() => {
|
|
2777
|
+
if (!mainPollExited) {
|
|
2778
|
+
// Main poll context — always return needs_confirmation so it exits
|
|
2779
|
+
// without delivering (the 5min timeout is simulated by having the
|
|
2780
|
+
// main poll see needs_confirmation until it gives up).
|
|
2781
|
+
return {
|
|
2782
|
+
id: run.id,
|
|
2783
|
+
conversationId: conversationId!,
|
|
2784
|
+
messageId: null,
|
|
2785
|
+
status: 'needs_confirmation' as const,
|
|
2786
|
+
pendingConfirmation: sampleConfirmation,
|
|
2787
|
+
pendingSecret: null,
|
|
2788
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2789
|
+
error: null,
|
|
2790
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
// Post-decision poll — return completed after a short delay
|
|
2794
|
+
postDecisionGetRunCount++;
|
|
2795
|
+
if (postDecisionGetRunCount <= 1) {
|
|
2796
|
+
return {
|
|
2797
|
+
id: run.id,
|
|
2798
|
+
conversationId: conversationId!,
|
|
2799
|
+
messageId: null,
|
|
2800
|
+
status: 'needs_confirmation' as const,
|
|
2801
|
+
pendingConfirmation: null,
|
|
2802
|
+
pendingSecret: null,
|
|
2803
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2804
|
+
error: null,
|
|
2805
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2806
|
+
};
|
|
2807
|
+
}
|
|
2808
|
+
return {
|
|
2809
|
+
id: run.id,
|
|
2810
|
+
conversationId: conversationId!,
|
|
2811
|
+
messageId: null,
|
|
2812
|
+
status: 'completed' as const,
|
|
2813
|
+
pendingConfirmation: null,
|
|
2814
|
+
pendingSecret: null,
|
|
2815
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2816
|
+
error: null,
|
|
2817
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2818
|
+
};
|
|
2819
|
+
}),
|
|
2820
|
+
startRun: mock(async () => ({
|
|
2821
|
+
id: run.id,
|
|
2822
|
+
conversationId: conversationId!,
|
|
2823
|
+
messageId: null,
|
|
2824
|
+
status: 'running' as const,
|
|
2825
|
+
pendingConfirmation: null,
|
|
2826
|
+
pendingSecret: null,
|
|
2827
|
+
inputTokens: 0, outputTokens: 0, estimatedCost: 0,
|
|
2828
|
+
error: null,
|
|
2829
|
+
createdAt: Date.now(), updatedAt: Date.now(),
|
|
2830
|
+
})),
|
|
2831
|
+
} as unknown as RunOrchestrator;
|
|
2832
|
+
|
|
2833
|
+
deliverSpy.mockClear();
|
|
2834
|
+
|
|
2835
|
+
// Start the main poll — it will see needs_confirmation and exit after
|
|
2836
|
+
// the first poll interval (marking the event as processed, not delivering).
|
|
2837
|
+
const msgReq = makeInboundRequest({ content: 'do something late' });
|
|
2838
|
+
await handleChannelInbound(msgReq, noopProcessMessage, 'token', lateOrchestrator);
|
|
2839
|
+
|
|
2840
|
+
// Wait for the main poll to see needs_confirmation and mark processed
|
|
2841
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
2842
|
+
mainPollExited = true;
|
|
2843
|
+
|
|
2844
|
+
// Now send the decision to trigger the post-decision delivery
|
|
2845
|
+
const decisionReq = makeInboundRequest({
|
|
2846
|
+
content: '',
|
|
2847
|
+
callbackData: `apr:${run.id}:approve_once`,
|
|
2848
|
+
});
|
|
2849
|
+
await handleChannelInbound(decisionReq, noopProcessMessage, 'token', lateOrchestrator);
|
|
2850
|
+
|
|
2851
|
+
// Wait for the post-decision poll to deliver
|
|
2852
|
+
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
2853
|
+
|
|
2854
|
+
// Count deliveries of the final assistant reply
|
|
2855
|
+
const replyDeliveryCalls = deliverSpy.mock.calls.filter(
|
|
2856
|
+
(call) => typeof call[1] === 'object' &&
|
|
2857
|
+
(call[1] as { text?: string }).text === 'Post-decision result.',
|
|
2858
|
+
);
|
|
2859
|
+
|
|
2860
|
+
// Exactly one delivery should have occurred (from the post-decision poll)
|
|
2861
|
+
expect(replyDeliveryCalls.length).toBe(1);
|
|
2862
|
+
|
|
2863
|
+
deliverSpy.mockRestore();
|
|
2864
|
+
});
|
|
2865
|
+
});
|
|
2866
|
+
|
|
2867
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2868
|
+
// 26. Assistant-scoped guardian verification via handleChannelInbound
|
|
2869
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
2870
|
+
|
|
2871
|
+
describe('assistant-scoped guardian verification via handleChannelInbound', () => {
|
|
2872
|
+
test('/guardian_verify uses the threaded assistantId (default: self)', async () => {
|
|
2873
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
2874
|
+
const { secret } = createVerificationChallenge('self', 'telegram');
|
|
2875
|
+
|
|
2876
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2877
|
+
|
|
2878
|
+
const req = makeInboundRequest({
|
|
2879
|
+
content: `/guardian_verify ${secret}`,
|
|
2880
|
+
senderExternalUserId: 'user-default-asst',
|
|
2881
|
+
});
|
|
2882
|
+
|
|
2883
|
+
// No assistantId passed => defaults to 'self'
|
|
2884
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token');
|
|
2885
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2886
|
+
|
|
2887
|
+
expect(body.accepted).toBe(true);
|
|
2888
|
+
expect(body.guardianVerification).toBe('verified');
|
|
2889
|
+
|
|
2890
|
+
deliverSpy.mockRestore();
|
|
2891
|
+
});
|
|
2892
|
+
|
|
2893
|
+
test('/guardian_verify with explicit assistantId resolves against that assistant', async () => {
|
|
2894
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
2895
|
+
const { getGuardianBinding } = await import('../runtime/channel-guardian-service.js');
|
|
2896
|
+
|
|
2897
|
+
// Create a challenge for asst-route-X
|
|
2898
|
+
const { secret } = createVerificationChallenge('asst-route-X', 'telegram');
|
|
2899
|
+
|
|
2900
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2901
|
+
|
|
2902
|
+
const req = makeInboundRequest({
|
|
2903
|
+
content: `/guardian_verify ${secret}`,
|
|
2904
|
+
senderExternalUserId: 'user-for-asst-x',
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
// Pass assistantId = 'asst-route-X'
|
|
2908
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', undefined, 'asst-route-X');
|
|
2909
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2910
|
+
|
|
2911
|
+
expect(body.accepted).toBe(true);
|
|
2912
|
+
expect(body.guardianVerification).toBe('verified');
|
|
2913
|
+
|
|
2914
|
+
// Binding should exist for asst-route-X, not for 'self'
|
|
2915
|
+
const bindingX = getGuardianBinding('asst-route-X', 'telegram');
|
|
2916
|
+
expect(bindingX).not.toBeNull();
|
|
2917
|
+
expect(bindingX!.guardianExternalUserId).toBe('user-for-asst-x');
|
|
2918
|
+
|
|
2919
|
+
deliverSpy.mockRestore();
|
|
2920
|
+
});
|
|
2921
|
+
|
|
2922
|
+
test('cross-assistant challenge verification fails', async () => {
|
|
2923
|
+
const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
|
|
2924
|
+
|
|
2925
|
+
// Create challenge for asst-A
|
|
2926
|
+
const { secret } = createVerificationChallenge('asst-A-cross', 'telegram');
|
|
2927
|
+
|
|
2928
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2929
|
+
|
|
2930
|
+
const req = makeInboundRequest({
|
|
2931
|
+
content: `/guardian_verify ${secret}`,
|
|
2932
|
+
senderExternalUserId: 'user-cross-test',
|
|
2933
|
+
});
|
|
2934
|
+
|
|
2935
|
+
// Try to verify using asst-B — should fail because the challenge is for asst-A
|
|
2936
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', undefined, 'asst-B-cross');
|
|
2937
|
+
const body = await res.json() as Record<string, unknown>;
|
|
2938
|
+
|
|
2939
|
+
expect(body.accepted).toBe(true);
|
|
2940
|
+
expect(body.guardianVerification).toBe('failed');
|
|
2941
|
+
|
|
2942
|
+
deliverSpy.mockRestore();
|
|
2943
|
+
});
|
|
2944
|
+
|
|
2945
|
+
test('actor role resolution uses threaded assistantId', async () => {
|
|
2946
|
+
|
|
2947
|
+
// Create guardian binding for asst-role-X
|
|
2948
|
+
createBinding({
|
|
2949
|
+
assistantId: 'asst-role-X',
|
|
2950
|
+
channel: 'telegram',
|
|
2951
|
+
guardianExternalUserId: 'guardian-role-user',
|
|
2952
|
+
guardianDeliveryChatId: 'guardian-role-chat',
|
|
2953
|
+
});
|
|
2954
|
+
|
|
2955
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2956
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2957
|
+
|
|
2958
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-role-scoped', terminalStatus: 'completed' });
|
|
2959
|
+
|
|
2960
|
+
// Non-guardian user sending to asst-role-X should be recognized as non-guardian
|
|
2961
|
+
const req = makeInboundRequest({
|
|
2962
|
+
content: 'do something dangerous',
|
|
2963
|
+
senderExternalUserId: 'non-guardian-role-user',
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator, 'asst-role-X');
|
|
2967
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
2968
|
+
|
|
2969
|
+
// The approval prompt should have been sent to the guardian's chat
|
|
2970
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
2971
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
2972
|
+
expect(approvalArgs[1]).toBe('guardian-role-chat');
|
|
2973
|
+
|
|
2974
|
+
deliverSpy.mockRestore();
|
|
2975
|
+
approvalSpy.mockRestore();
|
|
2976
|
+
});
|
|
2977
|
+
|
|
2978
|
+
test('same user is guardian for one assistant but not another', async () => {
|
|
2979
|
+
|
|
2980
|
+
// user-multi is guardian for asst-M1 but not asst-M2
|
|
2981
|
+
createBinding({
|
|
2982
|
+
assistantId: 'asst-M1',
|
|
2983
|
+
channel: 'telegram',
|
|
2984
|
+
guardianExternalUserId: 'user-multi',
|
|
2985
|
+
guardianDeliveryChatId: 'chat-multi',
|
|
2986
|
+
});
|
|
2987
|
+
createBinding({
|
|
2988
|
+
assistantId: 'asst-M2',
|
|
2989
|
+
channel: 'telegram',
|
|
2990
|
+
guardianExternalUserId: 'user-other-guardian',
|
|
2991
|
+
guardianDeliveryChatId: 'chat-other-guardian',
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
2995
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
2996
|
+
|
|
2997
|
+
// For asst-M1: user-multi is the guardian, so should get standard self-approval
|
|
2998
|
+
const orch1 = makeSensitiveOrchestrator({ runId: 'run-m1', terminalStatus: 'completed' });
|
|
2999
|
+
const req1 = makeInboundRequest({
|
|
3000
|
+
content: 'dangerous action',
|
|
3001
|
+
senderExternalUserId: 'user-multi',
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
await handleChannelInbound(req1, noopProcessMessage, 'token', orch1, 'asst-M1');
|
|
3005
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3006
|
+
|
|
3007
|
+
// For asst-M1, user-multi is guardian — approval prompt to own chat (standard flow)
|
|
3008
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3009
|
+
const m1ApprovalArgs = approvalSpy.mock.calls[0];
|
|
3010
|
+
// Should be sent to user-multi's own chat (chat-123 from makeInboundRequest default)
|
|
3011
|
+
expect(m1ApprovalArgs[1]).toBe('chat-123');
|
|
3012
|
+
|
|
3013
|
+
approvalSpy.mockClear();
|
|
3014
|
+
deliverSpy.mockClear();
|
|
3015
|
+
|
|
3016
|
+
// For asst-M2: user-multi is NOT the guardian, so approval should route to asst-M2's guardian
|
|
3017
|
+
const orch2 = makeSensitiveOrchestrator({ runId: 'run-m2', terminalStatus: 'completed' });
|
|
3018
|
+
const req2 = makeInboundRequest({
|
|
3019
|
+
content: 'another dangerous action',
|
|
3020
|
+
senderExternalUserId: 'user-multi',
|
|
3021
|
+
});
|
|
3022
|
+
|
|
3023
|
+
await handleChannelInbound(req2, noopProcessMessage, 'token', orch2, 'asst-M2');
|
|
3024
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3025
|
+
|
|
3026
|
+
// For asst-M2, user-multi is non-guardian — approval should go to user-other-guardian's chat
|
|
3027
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3028
|
+
const m2ApprovalArgs = approvalSpy.mock.calls[0];
|
|
3029
|
+
expect(m2ApprovalArgs[1]).toBe('chat-other-guardian');
|
|
3030
|
+
|
|
3031
|
+
deliverSpy.mockRestore();
|
|
3032
|
+
approvalSpy.mockRestore();
|
|
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
|
+
});
|
|
3065
|
+
});
|
|
3066
|
+
|
|
3067
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3068
|
+
// 27. Guardian enforcement behavior
|
|
3069
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3070
|
+
|
|
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();
|
|
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 () => {
|
|
3115
|
+
// Create a guardian binding — user-guardian is the guardian
|
|
3116
|
+
createBinding({
|
|
3117
|
+
assistantId: 'self',
|
|
3118
|
+
channel: 'telegram',
|
|
3119
|
+
guardianExternalUserId: 'user-guardian',
|
|
3120
|
+
guardianDeliveryChatId: 'chat-guardian',
|
|
3121
|
+
});
|
|
3122
|
+
|
|
3123
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-flag-off-guardian', terminalStatus: 'completed' });
|
|
3124
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3125
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3126
|
+
|
|
3127
|
+
const req = makeInboundRequest({
|
|
3128
|
+
content: 'do something dangerous',
|
|
3129
|
+
senderExternalUserId: 'user-non-guardian',
|
|
3130
|
+
});
|
|
3131
|
+
|
|
3132
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3133
|
+
expect(res.status).toBe(200);
|
|
3134
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3135
|
+
|
|
3136
|
+
expect(approvalSpy).toHaveBeenCalled();
|
|
3137
|
+
const approvalArgs = approvalSpy.mock.calls[0];
|
|
3138
|
+
expect(approvalArgs[1]).toBe('chat-guardian');
|
|
3139
|
+
|
|
3140
|
+
deliverSpy.mockRestore();
|
|
3141
|
+
approvalSpy.mockRestore();
|
|
3142
|
+
});
|
|
3143
|
+
|
|
3144
|
+
test('missing senderExternalUserId with guardian binding fails closed', async () => {
|
|
3145
|
+
|
|
3146
|
+
// Create a guardian binding — guardian enforcement is active
|
|
3147
|
+
createBinding({
|
|
3148
|
+
assistantId: 'self',
|
|
3149
|
+
channel: 'telegram',
|
|
3150
|
+
guardianExternalUserId: 'user-guardian',
|
|
3151
|
+
guardianDeliveryChatId: 'chat-guardian',
|
|
3152
|
+
});
|
|
3153
|
+
|
|
3154
|
+
// Use makeSensitiveOrchestrator so that getRun returns needs_confirmation
|
|
3155
|
+
// on the first poll (triggering the unverified_channel auto-deny path)
|
|
3156
|
+
// and then returns terminal state.
|
|
3157
|
+
const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-1', terminalStatus: 'failed' });
|
|
3158
|
+
|
|
3159
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3160
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3161
|
+
|
|
3162
|
+
// Send a message WITHOUT senderExternalUserId
|
|
3163
|
+
const req = makeInboundRequest({
|
|
3164
|
+
content: 'do something dangerous',
|
|
3165
|
+
senderExternalUserId: undefined,
|
|
3166
|
+
});
|
|
3167
|
+
|
|
3168
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3169
|
+
expect(res.status).toBe(200);
|
|
3170
|
+
|
|
3171
|
+
// Wait for background processing
|
|
3172
|
+
await new Promise((resolve) => setTimeout(resolve, 1200));
|
|
3173
|
+
|
|
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.
|
|
3184
|
+
const denialCalls = deliverSpy.mock.calls.filter(
|
|
3185
|
+
(call) => typeof call[1] === 'object'
|
|
3186
|
+
&& ((call[1] as { text?: string }).text ?? '').includes('identity could not be determined'),
|
|
3187
|
+
);
|
|
3188
|
+
expect(denialCalls.length).toBe(0);
|
|
3189
|
+
|
|
3190
|
+
// Auto-deny path should never prompt for approval
|
|
3191
|
+
expect(approvalSpy).not.toHaveBeenCalled();
|
|
3192
|
+
|
|
3193
|
+
deliverSpy.mockRestore();
|
|
3194
|
+
approvalSpy.mockRestore();
|
|
3195
|
+
});
|
|
3196
|
+
|
|
3197
|
+
test('missing senderExternalUserId without guardian binding fails closed', async () => {
|
|
3198
|
+
|
|
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' });
|
|
3202
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3203
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3204
|
+
|
|
3205
|
+
const req = makeInboundRequest({
|
|
3206
|
+
content: 'do something dangerous',
|
|
3207
|
+
senderExternalUserId: undefined,
|
|
3208
|
+
});
|
|
3209
|
+
|
|
3210
|
+
const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3211
|
+
expect(res.status).toBe(200);
|
|
3212
|
+
|
|
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();
|
|
3228
|
+
|
|
3229
|
+
deliverSpy.mockRestore();
|
|
3230
|
+
approvalSpy.mockRestore();
|
|
3231
|
+
});
|
|
3232
|
+
});
|
|
3233
|
+
|
|
3234
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3235
|
+
// 28. Gateway-origin proof hardening — dedicated secret support
|
|
3236
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3237
|
+
|
|
3238
|
+
describe('verifyGatewayOrigin with dedicated gateway-origin secret', () => {
|
|
3239
|
+
function makeReqWithHeader(value?: string): Request {
|
|
3240
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
3241
|
+
if (value !== undefined) {
|
|
3242
|
+
headers['X-Gateway-Origin'] = value;
|
|
3243
|
+
}
|
|
3244
|
+
return new Request('http://localhost/channels/inbound', {
|
|
3245
|
+
method: 'POST',
|
|
3246
|
+
headers,
|
|
3247
|
+
body: '{}',
|
|
3248
|
+
});
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
test('returns true when no secrets configured (local dev)', () => {
|
|
3252
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), undefined, undefined)).toBe(true);
|
|
3253
|
+
});
|
|
3254
|
+
|
|
3255
|
+
test('falls back to bearerToken when no dedicated secret is set', () => {
|
|
3256
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('my-bearer'), 'my-bearer', undefined)).toBe(true);
|
|
3257
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('wrong'), 'my-bearer', undefined)).toBe(false);
|
|
3258
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), 'my-bearer', undefined)).toBe(false);
|
|
3259
|
+
});
|
|
3260
|
+
|
|
3261
|
+
test('uses dedicated secret when set, ignoring bearer token', () => {
|
|
3262
|
+
// Dedicated secret matches — should pass even if bearer token differs
|
|
3263
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('dedicated-secret'), 'bearer-token', 'dedicated-secret')).toBe(true);
|
|
3264
|
+
// Bearer token matches but dedicated secret doesn't — should fail
|
|
3265
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('bearer-token'), 'bearer-token', 'dedicated-secret')).toBe(false);
|
|
3266
|
+
});
|
|
3267
|
+
|
|
3268
|
+
test('validates dedicated secret even when bearer token is not configured', () => {
|
|
3269
|
+
// No bearer token but dedicated secret is set — should validate against it
|
|
3270
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('my-secret'), undefined, 'my-secret')).toBe(true);
|
|
3271
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('wrong'), undefined, 'my-secret')).toBe(false);
|
|
3272
|
+
});
|
|
3273
|
+
|
|
3274
|
+
test('rejects missing header when any secret is configured', () => {
|
|
3275
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), 'bearer', undefined)).toBe(false);
|
|
3276
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), undefined, 'secret')).toBe(false);
|
|
3277
|
+
expect(verifyGatewayOrigin(makeReqWithHeader(), 'bearer', 'secret')).toBe(false);
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
test('rejects mismatched length headers (constant-time comparison guard)', () => {
|
|
3281
|
+
// Different lengths should be rejected without timing leaks
|
|
3282
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('short'), 'a-much-longer-secret', undefined)).toBe(false);
|
|
3283
|
+
expect(verifyGatewayOrigin(makeReqWithHeader('a-much-longer-secret'), 'short', undefined)).toBe(false);
|
|
3284
|
+
});
|
|
3285
|
+
});
|
|
3286
|
+
|
|
3287
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3288
|
+
// 29. handleChannelInbound passes gatewayOriginSecret to verifyGatewayOrigin
|
|
3289
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3290
|
+
|
|
3291
|
+
describe('handleChannelInbound gatewayOriginSecret integration', () => {
|
|
3292
|
+
test('rejects request when bearer token matches but dedicated secret does not', async () => {
|
|
3293
|
+
const bearerToken = 'my-bearer';
|
|
3294
|
+
const gatewaySecret = 'dedicated-gw-secret';
|
|
3295
|
+
|
|
3296
|
+
// Request carries the bearer token as X-Gateway-Origin, but the
|
|
3297
|
+
// dedicated secret is configured — verifyGatewayOrigin should require
|
|
3298
|
+
// the dedicated secret, not the bearer token.
|
|
3299
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
3300
|
+
method: 'POST',
|
|
3301
|
+
headers: {
|
|
3302
|
+
'Content-Type': 'application/json',
|
|
3303
|
+
'X-Gateway-Origin': bearerToken,
|
|
3304
|
+
},
|
|
3305
|
+
body: JSON.stringify({
|
|
3306
|
+
sourceChannel: 'telegram',
|
|
3307
|
+
externalChatId: 'chat-gw-secret-test',
|
|
3308
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
3309
|
+
content: 'hello',
|
|
3310
|
+
}),
|
|
3311
|
+
});
|
|
3312
|
+
|
|
3313
|
+
const res = await handleChannelInbound(
|
|
3314
|
+
req, noopProcessMessage, bearerToken, undefined, 'self', gatewaySecret,
|
|
3315
|
+
);
|
|
3316
|
+
expect(res.status).toBe(403);
|
|
3317
|
+
const body = await res.json() as { code: string };
|
|
3318
|
+
expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
|
|
3319
|
+
});
|
|
3320
|
+
|
|
3321
|
+
test('accepts request when dedicated secret matches', async () => {
|
|
3322
|
+
const bearerToken = 'my-bearer';
|
|
3323
|
+
const gatewaySecret = 'dedicated-gw-secret';
|
|
3324
|
+
|
|
3325
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
3326
|
+
method: 'POST',
|
|
3327
|
+
headers: {
|
|
3328
|
+
'Content-Type': 'application/json',
|
|
3329
|
+
'X-Gateway-Origin': gatewaySecret,
|
|
3330
|
+
},
|
|
3331
|
+
body: JSON.stringify({
|
|
3332
|
+
sourceChannel: 'telegram',
|
|
3333
|
+
externalChatId: 'chat-gw-secret-pass',
|
|
3334
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
3335
|
+
content: 'hello',
|
|
3336
|
+
}),
|
|
3337
|
+
});
|
|
3338
|
+
|
|
3339
|
+
const res = await handleChannelInbound(
|
|
3340
|
+
req, noopProcessMessage, bearerToken, undefined, 'self', gatewaySecret,
|
|
3341
|
+
);
|
|
3342
|
+
// Should pass the gateway-origin check and proceed to normal processing
|
|
3343
|
+
expect(res.status).toBe(200);
|
|
3344
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3345
|
+
expect(body.accepted).toBe(true);
|
|
3346
|
+
});
|
|
3347
|
+
|
|
3348
|
+
test('falls back to bearer token when no dedicated secret is set', async () => {
|
|
3349
|
+
const bearerToken = 'my-bearer';
|
|
3350
|
+
|
|
3351
|
+
const req = new Request('http://localhost/channels/inbound', {
|
|
3352
|
+
method: 'POST',
|
|
3353
|
+
headers: {
|
|
3354
|
+
'Content-Type': 'application/json',
|
|
3355
|
+
'X-Gateway-Origin': bearerToken,
|
|
3356
|
+
},
|
|
3357
|
+
body: JSON.stringify({
|
|
3358
|
+
sourceChannel: 'telegram',
|
|
3359
|
+
externalChatId: 'chat-gw-fallback',
|
|
3360
|
+
externalMessageId: `msg-${Date.now()}-${Math.random()}`,
|
|
3361
|
+
content: 'hello',
|
|
3362
|
+
}),
|
|
3363
|
+
});
|
|
3364
|
+
|
|
3365
|
+
// No gatewayOriginSecret (6th param undefined) — should fall back to bearer
|
|
3366
|
+
const res = await handleChannelInbound(
|
|
3367
|
+
req, noopProcessMessage, bearerToken, undefined, 'self', undefined,
|
|
3368
|
+
);
|
|
3369
|
+
expect(res.status).toBe(200);
|
|
3370
|
+
const body = await res.json() as Record<string, unknown>;
|
|
3371
|
+
expect(body.accepted).toBe(true);
|
|
3372
|
+
});
|
|
3373
|
+
});
|
|
3374
|
+
|
|
3375
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3376
|
+
// 30. Unknown actor identity — forceStrictSideEffects propagation
|
|
3377
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3378
|
+
|
|
3379
|
+
describe('unknown actor identity — forceStrictSideEffects', () => {
|
|
3380
|
+
beforeEach(() => {
|
|
3381
|
+
});
|
|
3382
|
+
|
|
3383
|
+
test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {
|
|
3384
|
+
// Create a guardian binding so the channel is guardian-enforced
|
|
3385
|
+
createBinding({
|
|
3386
|
+
assistantId: 'self',
|
|
3387
|
+
channel: 'telegram',
|
|
3388
|
+
guardianExternalUserId: 'known-guardian',
|
|
3389
|
+
guardianDeliveryChatId: 'guardian-chat',
|
|
3390
|
+
});
|
|
3391
|
+
|
|
3392
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3393
|
+
|
|
3394
|
+
const mockRun = {
|
|
3395
|
+
id: 'run-unknown-actor',
|
|
3396
|
+
conversationId: 'conv-1',
|
|
3397
|
+
messageId: null,
|
|
3398
|
+
status: 'running' as const,
|
|
3399
|
+
pendingConfirmation: null,
|
|
3400
|
+
pendingSecret: null,
|
|
3401
|
+
inputTokens: 0,
|
|
3402
|
+
outputTokens: 0,
|
|
3403
|
+
estimatedCost: 0,
|
|
3404
|
+
error: null,
|
|
3405
|
+
createdAt: Date.now(),
|
|
3406
|
+
updatedAt: Date.now(),
|
|
3407
|
+
};
|
|
3408
|
+
|
|
3409
|
+
const orchestrator = {
|
|
3410
|
+
submitDecision: mock(() => 'applied' as const),
|
|
3411
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
3412
|
+
startRun: mock(async () => mockRun),
|
|
3413
|
+
} as unknown as RunOrchestrator;
|
|
3414
|
+
|
|
3415
|
+
// Send message with no senderExternalUserId — the unknown actor should
|
|
3416
|
+
// be classified as unverified_channel and forceStrictSideEffects set.
|
|
3417
|
+
const req = makeInboundRequest({
|
|
3418
|
+
content: 'do something',
|
|
3419
|
+
senderExternalUserId: undefined,
|
|
3420
|
+
});
|
|
3421
|
+
|
|
3422
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3423
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
3424
|
+
|
|
3425
|
+
// startRun should have been called with forceStrictSideEffects: true
|
|
3426
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3427
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
3428
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean } | undefined;
|
|
3429
|
+
expect(options).toBeDefined();
|
|
3430
|
+
expect(options!.forceStrictSideEffects).toBe(true);
|
|
3431
|
+
|
|
3432
|
+
deliverSpy.mockRestore();
|
|
3433
|
+
});
|
|
3434
|
+
|
|
3435
|
+
test('known non-guardian sender with guardian binding gets forceStrictSideEffects', async () => {
|
|
3436
|
+
createBinding({
|
|
3437
|
+
assistantId: 'self',
|
|
3438
|
+
channel: 'telegram',
|
|
3439
|
+
guardianExternalUserId: 'the-guardian',
|
|
3440
|
+
guardianDeliveryChatId: 'guardian-chat-2',
|
|
3441
|
+
});
|
|
3442
|
+
|
|
3443
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3444
|
+
const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
|
|
3445
|
+
|
|
3446
|
+
const mockRun = {
|
|
3447
|
+
id: 'run-nongrd-strict',
|
|
3448
|
+
conversationId: 'conv-1',
|
|
3449
|
+
messageId: null,
|
|
3450
|
+
status: 'running' as const,
|
|
3451
|
+
pendingConfirmation: null,
|
|
3452
|
+
pendingSecret: null,
|
|
3453
|
+
inputTokens: 0,
|
|
3454
|
+
outputTokens: 0,
|
|
3455
|
+
estimatedCost: 0,
|
|
3456
|
+
error: null,
|
|
3457
|
+
createdAt: Date.now(),
|
|
3458
|
+
updatedAt: Date.now(),
|
|
3459
|
+
};
|
|
3460
|
+
|
|
3461
|
+
const orchestrator = {
|
|
3462
|
+
submitDecision: mock(() => 'applied' as const),
|
|
3463
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
3464
|
+
startRun: mock(async () => mockRun),
|
|
3465
|
+
} as unknown as RunOrchestrator;
|
|
3466
|
+
|
|
3467
|
+
// Non-guardian user sends a message
|
|
3468
|
+
const req = makeInboundRequest({
|
|
3469
|
+
content: 'do something',
|
|
3470
|
+
senderExternalUserId: 'not-the-guardian',
|
|
3471
|
+
});
|
|
3472
|
+
|
|
3473
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3474
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
3475
|
+
|
|
3476
|
+
// startRun should have been called with forceStrictSideEffects: true
|
|
3477
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3478
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
3479
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean } | undefined;
|
|
3480
|
+
expect(options).toBeDefined();
|
|
3481
|
+
expect(options!.forceStrictSideEffects).toBe(true);
|
|
3482
|
+
|
|
3483
|
+
deliverSpy.mockRestore();
|
|
3484
|
+
approvalSpy.mockRestore();
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
test('guardian sender does NOT get forceStrictSideEffects', async () => {
|
|
3488
|
+
createBinding({
|
|
3489
|
+
assistantId: 'self',
|
|
3490
|
+
channel: 'telegram',
|
|
3491
|
+
guardianExternalUserId: 'the-guardian',
|
|
3492
|
+
guardianDeliveryChatId: 'guardian-chat-3',
|
|
3493
|
+
});
|
|
3494
|
+
|
|
3495
|
+
const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
|
|
3496
|
+
|
|
3497
|
+
const mockRun = {
|
|
3498
|
+
id: 'run-grd-no-strict',
|
|
3499
|
+
conversationId: 'conv-1',
|
|
3500
|
+
messageId: null,
|
|
3501
|
+
status: 'running' as const,
|
|
3502
|
+
pendingConfirmation: null,
|
|
3503
|
+
pendingSecret: null,
|
|
3504
|
+
inputTokens: 0,
|
|
3505
|
+
outputTokens: 0,
|
|
3506
|
+
estimatedCost: 0,
|
|
3507
|
+
error: null,
|
|
3508
|
+
createdAt: Date.now(),
|
|
3509
|
+
updatedAt: Date.now(),
|
|
3510
|
+
};
|
|
3511
|
+
|
|
3512
|
+
const orchestrator = {
|
|
3513
|
+
submitDecision: mock(() => 'applied' as const),
|
|
3514
|
+
getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
|
|
3515
|
+
startRun: mock(async () => mockRun),
|
|
3516
|
+
} as unknown as RunOrchestrator;
|
|
3517
|
+
|
|
3518
|
+
// The guardian sends a message — should NOT get forceStrictSideEffects
|
|
3519
|
+
const req = makeInboundRequest({
|
|
3520
|
+
content: 'do something',
|
|
3521
|
+
senderExternalUserId: 'the-guardian',
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
|
|
3525
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
3526
|
+
|
|
3527
|
+
expect(orchestrator.startRun).toHaveBeenCalled();
|
|
3528
|
+
const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
|
|
3529
|
+
const options = startRunArgs[3] as { forceStrictSideEffects?: boolean; sourceChannel?: string } | undefined;
|
|
3530
|
+
expect(options).toBeDefined();
|
|
3531
|
+
// Guardian should NOT have forceStrictSideEffects set
|
|
3532
|
+
expect(options!.forceStrictSideEffects).toBeUndefined();
|
|
3533
|
+
|
|
3534
|
+
deliverSpy.mockRestore();
|
|
3535
|
+
});
|
|
3536
|
+
});
|