@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.
Files changed (109) hide show
  1. package/README.md +82 -21
  2. package/package.json +1 -1
  3. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +16 -0
  4. package/src/__tests__/app-git-history.test.ts +22 -27
  5. package/src/__tests__/app-git-service.test.ts +44 -78
  6. package/src/__tests__/call-orchestrator.test.ts +321 -0
  7. package/src/__tests__/channel-approval-routes.test.ts +1267 -93
  8. package/src/__tests__/channel-approval.test.ts +2 -0
  9. package/src/__tests__/channel-approvals.test.ts +51 -2
  10. package/src/__tests__/channel-delivery-store.test.ts +130 -1
  11. package/src/__tests__/channel-guardian.test.ts +371 -1
  12. package/src/__tests__/config-schema.test.ts +1 -1
  13. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  14. package/src/__tests__/daemon-lifecycle.test.ts +635 -0
  15. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  16. package/src/__tests__/gateway-only-enforcement.test.ts +106 -21
  17. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  18. package/src/__tests__/handlers-twilio-config.test.ts +738 -5
  19. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  20. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  21. package/src/__tests__/run-orchestrator.test.ts +1 -1
  22. package/src/__tests__/secret-scanner.test.ts +223 -0
  23. package/src/__tests__/session-process-bridge.test.ts +2 -0
  24. package/src/__tests__/shell-parser-property.test.ts +357 -2
  25. package/src/__tests__/system-prompt.test.ts +25 -1
  26. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  27. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  28. package/src/__tests__/user-reference.test.ts +68 -0
  29. package/src/calls/call-orchestrator.ts +63 -11
  30. package/src/calls/twilio-config.ts +10 -1
  31. package/src/calls/twilio-rest.ts +70 -0
  32. package/src/cli/map.ts +6 -0
  33. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  34. package/src/commands/cc-command-registry.ts +14 -1
  35. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  36. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  37. package/src/config/bundled-skills/messaging/SKILL.md +4 -0
  38. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  39. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  40. package/src/config/defaults.ts +1 -1
  41. package/src/config/schema.ts +6 -3
  42. package/src/config/skills.ts +5 -32
  43. package/src/config/system-prompt.ts +16 -0
  44. package/src/config/user-reference.ts +29 -0
  45. package/src/config/vellum-skills/catalog.json +52 -0
  46. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  47. package/src/config/vellum-skills/twilio-setup/SKILL.md +49 -4
  48. package/src/daemon/auth-manager.ts +103 -0
  49. package/src/daemon/computer-use-session.ts +8 -1
  50. package/src/daemon/config-watcher.ts +253 -0
  51. package/src/daemon/handlers/config.ts +193 -17
  52. package/src/daemon/handlers/sessions.ts +5 -3
  53. package/src/daemon/handlers/skills.ts +60 -17
  54. package/src/daemon/ipc-contract-inventory.json +4 -0
  55. package/src/daemon/ipc-contract.ts +16 -0
  56. package/src/daemon/ipc-handler.ts +87 -0
  57. package/src/daemon/lifecycle.ts +16 -4
  58. package/src/daemon/ride-shotgun-handler.ts +11 -1
  59. package/src/daemon/server.ts +105 -502
  60. package/src/daemon/session-agent-loop.ts +9 -14
  61. package/src/daemon/session-process.ts +20 -3
  62. package/src/daemon/session-runtime-assembly.ts +60 -44
  63. package/src/daemon/session-slash.ts +50 -2
  64. package/src/daemon/session-surfaces.ts +17 -1
  65. package/src/daemon/session.ts +8 -1
  66. package/src/inbound/public-ingress-urls.ts +20 -3
  67. package/src/index.ts +1 -23
  68. package/src/memory/app-git-service.ts +24 -0
  69. package/src/memory/app-store.ts +0 -21
  70. package/src/memory/channel-delivery-store.ts +74 -3
  71. package/src/memory/channel-guardian-store.ts +54 -26
  72. package/src/memory/conversation-key-store.ts +20 -0
  73. package/src/memory/conversation-store.ts +14 -2
  74. package/src/memory/db-connection.ts +28 -0
  75. package/src/memory/db-init.ts +1019 -0
  76. package/src/memory/db.ts +2 -1995
  77. package/src/memory/embedding-backend.ts +79 -11
  78. package/src/memory/indexer.ts +2 -0
  79. package/src/memory/job-utils.ts +64 -4
  80. package/src/memory/jobs-worker.ts +7 -1
  81. package/src/memory/recall-cache.ts +107 -0
  82. package/src/memory/retriever.ts +30 -1
  83. package/src/memory/schema-migration.ts +984 -0
  84. package/src/memory/schema.ts +6 -0
  85. package/src/memory/search/types.ts +2 -0
  86. package/src/permissions/prompter.ts +14 -3
  87. package/src/permissions/trust-store.ts +7 -0
  88. package/src/runtime/channel-approvals.ts +17 -3
  89. package/src/runtime/gateway-client.ts +2 -1
  90. package/src/runtime/http-server.ts +28 -9
  91. package/src/runtime/routes/channel-routes.ts +279 -100
  92. package/src/runtime/routes/run-routes.ts +7 -1
  93. package/src/runtime/run-orchestrator.ts +8 -1
  94. package/src/security/secret-scanner.ts +218 -0
  95. package/src/skills/clawhub.ts +6 -2
  96. package/src/skills/frontmatter.ts +63 -0
  97. package/src/skills/slash-commands.ts +23 -0
  98. package/src/skills/vellum-catalog-remote.ts +107 -0
  99. package/src/subagent/manager.ts +4 -1
  100. package/src/subagent/types.ts +2 -0
  101. package/src/tools/browser/auto-navigate.ts +132 -24
  102. package/src/tools/browser/browser-manager.ts +67 -61
  103. package/src/tools/claude-code/claude-code.ts +55 -3
  104. package/src/tools/executor.ts +10 -2
  105. package/src/tools/skills/vellum-catalog.ts +75 -127
  106. package/src/tools/subagent/spawn.ts +2 -0
  107. package/src/tools/terminal/parser.ts +21 -5
  108. package/src/util/platform.ts +8 -1
  109. package/src/util/retry.ts +4 -4
@@ -1,7 +1,8 @@
1
- import { describe, test, expect, beforeEach, afterAll, afterEach, mock, spyOn } from 'bun:test';
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: { 'Content-Type': 'application/json' },
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
- afterEach(() => {
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
- // Should proceed normally no approval interception
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).toBeUndefined();
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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: { 'Content-Type': 'application/json' },
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, 'token', orchestrator);
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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 needs_confirmation run is waiting for approval decision.
1072
- // The event should be marked as processed because the post-decision delivery
1073
- // in handleApprovalInterception will deliver the reply when the user decides.
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(() => ({ ...mockRun, status: 'needs_confirmation' as const })),
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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, 'token');
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: { 'Content-Type': 'application/json' },
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, 'token');
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: { 'Content-Type': 'application/json' },
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, 'token', orchestrator);
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
- process.env.CHANNEL_APPROVALS_ENABLED = 'true';
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 and setup notice', async () => {
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 requester should have been notified about missing guardian setup
1799
- const replyCalls = deliverSpy.mock.calls.filter(
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(replyCalls.length).toBeGreaterThanOrEqual(1);
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 denial notice should have been sent
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).toBeGreaterThanOrEqual(1);
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
+ });