@vellumai/assistant 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +82 -13
  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__/channel-approval-routes.test.ts +930 -14
  7. package/src/__tests__/channel-approval.test.ts +2 -0
  8. package/src/__tests__/channel-delivery-store.test.ts +104 -1
  9. package/src/__tests__/channel-guardian.test.ts +184 -1
  10. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  11. package/src/__tests__/daemon-server-session-init.test.ts +5 -0
  12. package/src/__tests__/gateway-only-enforcement.test.ts +87 -8
  13. package/src/__tests__/handlers-telegram-config.test.ts +82 -0
  14. package/src/__tests__/handlers-twilio-config.test.ts +665 -5
  15. package/src/__tests__/ingress-url-consistency.test.ts +64 -0
  16. package/src/__tests__/ipc-snapshot.test.ts +10 -0
  17. package/src/__tests__/run-orchestrator.test.ts +1 -1
  18. package/src/__tests__/session-process-bridge.test.ts +2 -0
  19. package/src/__tests__/tool-permission-simulate-handler.test.ts +2 -2
  20. package/src/calls/twilio-config.ts +10 -1
  21. package/src/calls/twilio-rest.ts +70 -0
  22. package/src/config/bundled-skills/email-setup/SKILL.md +56 -0
  23. package/src/config/bundled-skills/subagent/SKILL.md +4 -0
  24. package/src/config/bundled-skills/subagent/TOOLS.json +4 -0
  25. package/src/config/schema.ts +3 -0
  26. package/src/config/vellum-skills/twilio-setup/SKILL.md +11 -4
  27. package/src/daemon/handlers/config.ts +168 -15
  28. package/src/daemon/handlers/sessions.ts +5 -3
  29. package/src/daemon/handlers/skills.ts +61 -17
  30. package/src/daemon/ipc-contract-inventory.json +4 -0
  31. package/src/daemon/ipc-contract.ts +10 -0
  32. package/src/daemon/session-agent-loop.ts +4 -0
  33. package/src/daemon/session-process.ts +20 -3
  34. package/src/daemon/session-slash.ts +50 -2
  35. package/src/daemon/session-surfaces.ts +17 -1
  36. package/src/inbound/public-ingress-urls.ts +20 -3
  37. package/src/index.ts +1 -23
  38. package/src/memory/app-git-service.ts +24 -0
  39. package/src/memory/app-store.ts +0 -21
  40. package/src/memory/channel-delivery-store.ts +74 -3
  41. package/src/memory/channel-guardian-store.ts +54 -26
  42. package/src/memory/conversation-key-store.ts +20 -0
  43. package/src/memory/conversation-store.ts +14 -2
  44. package/src/memory/db.ts +12 -0
  45. package/src/memory/schema.ts +5 -0
  46. package/src/runtime/http-server.ts +13 -5
  47. package/src/runtime/routes/channel-routes.ts +134 -43
  48. package/src/skills/clawhub.ts +6 -2
  49. package/src/subagent/manager.ts +4 -1
  50. package/src/subagent/types.ts +2 -0
  51. package/src/tools/skills/vellum-catalog.ts +45 -2
  52. package/src/tools/subagent/spawn.ts +2 -0
@@ -54,14 +54,14 @@ import {
54
54
  createApprovalRequest,
55
55
  getPendingApprovalForRun,
56
56
  getUnresolvedApprovalForRun,
57
- getExpiredPendingApprovals,
58
- updateApprovalDecision,
59
57
  } from '../memory/channel-guardian-store.js';
60
58
  import type { RunOrchestrator } from '../runtime/run-orchestrator.js';
61
59
  import {
62
60
  handleChannelInbound,
63
61
  isChannelApprovalsEnabled,
64
62
  sweepExpiredGuardianApprovals,
63
+ verifyGatewayOrigin,
64
+ _setTestPollMaxWait,
65
65
  } from '../runtime/routes/channel-routes.js';
66
66
  import * as gatewayClient from '../runtime/gateway-client.js';
67
67
 
@@ -98,6 +98,7 @@ function resetTables(): void {
98
98
  db.run('DELETE FROM channel_inbound_events');
99
99
  db.run('DELETE FROM messages');
100
100
  db.run('DELETE FROM conversations');
101
+ channelDeliveryStore.resetAllRunDeliveryClaims();
101
102
  }
102
103
 
103
104
  const sampleConfirmation: PendingConfirmation = {
@@ -132,6 +133,10 @@ function makeMockOrchestrator(
132
133
  } as unknown as RunOrchestrator;
133
134
  }
134
135
 
136
+ /** Default bearer token used by tests. Include the X-Gateway-Origin header
137
+ * so that verifyGatewayOrigin does not reject the request. */
138
+ const TEST_BEARER_TOKEN = 'token';
139
+
135
140
  function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
136
141
  const body = {
137
142
  sourceChannel: 'telegram',
@@ -143,7 +148,10 @@ function makeInboundRequest(overrides: Record<string, unknown> = {}): Request {
143
148
  };
144
149
  return new Request('http://localhost/channels/inbound', {
145
150
  method: 'POST',
146
- headers: { 'Content-Type': 'application/json' },
151
+ headers: {
152
+ 'Content-Type': 'application/json',
153
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
154
+ },
147
155
  body: JSON.stringify(body),
148
156
  });
149
157
  }
@@ -531,11 +539,14 @@ describe('empty content with callbackData bypasses validation', () => {
531
539
  };
532
540
  const req = new Request('http://localhost/channels/inbound', {
533
541
  method: 'POST',
534
- headers: { 'Content-Type': 'application/json' },
542
+ headers: {
543
+ 'Content-Type': 'application/json',
544
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
545
+ },
535
546
  body: JSON.stringify(body),
536
547
  });
537
548
 
538
- const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
549
+ const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN, orchestrator);
539
550
  expect(res.status).toBe(200);
540
551
  const resBody = await res.json() as Record<string, unknown>;
541
552
  expect(resBody.accepted).toBe(true);
@@ -1048,6 +1059,10 @@ describe('poll timeout handling by run state', () => {
1048
1059
  });
1049
1060
 
1050
1061
  test('marks event as processed when run is in needs_confirmation state after poll timeout', async () => {
1062
+ // Use a short poll timeout so the test can exercise the timeout path
1063
+ // without waiting 5 minutes.
1064
+ _setTestPollMaxWait(600);
1065
+
1051
1066
  const linkSpy = spyOn(channelDeliveryStore, 'linkMessage').mockImplementation(() => {});
1052
1067
  const markSpy = spyOn(channelDeliveryStore, 'markProcessed');
1053
1068
  const failureSpy = spyOn(channelDeliveryStore, 'recordProcessingFailure').mockImplementation(() => {});
@@ -1080,8 +1095,8 @@ describe('poll timeout handling by run state', () => {
1080
1095
  const req = makeInboundRequest({ content: 'hello needs_confirm' });
1081
1096
  await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1082
1097
 
1083
- // Wait for the background async to complete
1084
- await new Promise((resolve) => setTimeout(resolve, 800));
1098
+ // Wait for the background async to complete (poll timeout is 600ms + one poll interval of 500ms)
1099
+ await new Promise((resolve) => setTimeout(resolve, 1500));
1085
1100
 
1086
1101
  // markProcessed SHOULD have been called — the run is waiting for approval,
1087
1102
  // and the post-decision delivery path will handle the final reply.
@@ -1094,6 +1109,7 @@ describe('poll timeout handling by run state', () => {
1094
1109
  markSpy.mockRestore();
1095
1110
  failureSpy.mockRestore();
1096
1111
  deliverSpy.mockRestore();
1112
+ _setTestPollMaxWait(null);
1097
1113
  });
1098
1114
 
1099
1115
  test('does NOT call recordProcessingFailure when run reaches terminal state', async () => {
@@ -1335,7 +1351,10 @@ describe('SMS channel approval decisions', () => {
1335
1351
  };
1336
1352
  return new Request('http://localhost/channels/inbound', {
1337
1353
  method: 'POST',
1338
- headers: { 'Content-Type': 'application/json' },
1354
+ headers: {
1355
+ 'Content-Type': 'application/json',
1356
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
1357
+ },
1339
1358
  body: JSON.stringify(body),
1340
1359
  });
1341
1360
  }
@@ -1481,7 +1500,10 @@ describe('SMS guardian verify intercept', () => {
1481
1500
 
1482
1501
  const req = new Request('http://localhost/channels/inbound', {
1483
1502
  method: 'POST',
1484
- headers: { 'Content-Type': 'application/json' },
1503
+ headers: {
1504
+ 'Content-Type': 'application/json',
1505
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
1506
+ },
1485
1507
  body: JSON.stringify({
1486
1508
  sourceChannel: 'sms',
1487
1509
  externalChatId: 'sms-chat-verify',
@@ -1492,7 +1514,7 @@ describe('SMS guardian verify intercept', () => {
1492
1514
  }),
1493
1515
  });
1494
1516
 
1495
- const res = await handleChannelInbound(req, noopProcessMessage, 'token');
1517
+ const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN);
1496
1518
  const body = await res.json() as Record<string, unknown>;
1497
1519
 
1498
1520
  expect(body.accepted).toBe(true);
@@ -1513,7 +1535,10 @@ describe('SMS guardian verify intercept', () => {
1513
1535
 
1514
1536
  const req = new Request('http://localhost/channels/inbound', {
1515
1537
  method: 'POST',
1516
- headers: { 'Content-Type': 'application/json' },
1538
+ headers: {
1539
+ 'Content-Type': 'application/json',
1540
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
1541
+ },
1517
1542
  body: JSON.stringify({
1518
1543
  sourceChannel: 'sms',
1519
1544
  externalChatId: 'sms-chat-verify-fail',
@@ -1524,7 +1549,7 @@ describe('SMS guardian verify intercept', () => {
1524
1549
  }),
1525
1550
  });
1526
1551
 
1527
- const res = await handleChannelInbound(req, noopProcessMessage, 'token');
1552
+ const res = await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN);
1528
1553
  const body = await res.json() as Record<string, unknown>;
1529
1554
 
1530
1555
  expect(body.accepted).toBe(true);
@@ -1585,7 +1610,10 @@ describe('SMS non-guardian actor gating', () => {
1585
1610
  // Send message from a NON-guardian sms user
1586
1611
  const req = new Request('http://localhost/channels/inbound', {
1587
1612
  method: 'POST',
1588
- headers: { 'Content-Type': 'application/json' },
1613
+ headers: {
1614
+ 'Content-Type': 'application/json',
1615
+ 'X-Gateway-Origin': TEST_BEARER_TOKEN,
1616
+ },
1589
1617
  body: JSON.stringify({
1590
1618
  sourceChannel: 'sms',
1591
1619
  externalChatId: 'sms-other-chat',
@@ -1596,7 +1624,7 @@ describe('SMS non-guardian actor gating', () => {
1596
1624
  }),
1597
1625
  });
1598
1626
 
1599
- await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
1627
+ await handleChannelInbound(req, noopProcessMessage, TEST_BEARER_TOKEN, orchestrator);
1600
1628
 
1601
1629
  // Wait for the background async to fire
1602
1630
  await new Promise((resolve) => setTimeout(resolve, 800));
@@ -2074,6 +2102,52 @@ describe('guardian delivery failure → denial', () => {
2074
2102
  });
2075
2103
  });
2076
2104
 
2105
+ // ═══════════════════════════════════════════════════════════════════════════
2106
+ // 20b. Standard approval prompt delivery failure → auto-deny (WS-B)
2107
+ // ═══════════════════════════════════════════════════════════════════════════
2108
+
2109
+ describe('standard approval prompt delivery failure → auto-deny', () => {
2110
+ beforeEach(() => {
2111
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2112
+ });
2113
+
2114
+ test('standard prompt delivery failure auto-denies the run (fail-closed)', async () => {
2115
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2116
+ // Make the approval prompt delivery fail for the standard (self-approval) path
2117
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockRejectedValue(
2118
+ new Error('Network error: approval prompt unreachable'),
2119
+ );
2120
+
2121
+ // No guardian binding — sender is a guardian (default role), so the
2122
+ // standard self-approval path is used.
2123
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-std-fail', terminalStatus: 'failed' });
2124
+
2125
+ const req = makeInboundRequest({
2126
+ content: 'do something dangerous',
2127
+ senderExternalUserId: 'guardian-std-user',
2128
+ });
2129
+
2130
+ // Set up a guardian binding so the sender is recognized as guardian
2131
+ createBinding({
2132
+ assistantId: 'self',
2133
+ channel: 'telegram',
2134
+ guardianExternalUserId: 'guardian-std-user',
2135
+ guardianDeliveryChatId: 'chat-123',
2136
+ });
2137
+
2138
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2139
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2140
+
2141
+ // The run should have been auto-denied because the prompt could not be delivered
2142
+ expect(orchestrator.submitDecision).toHaveBeenCalled();
2143
+ const decisionArgs = (orchestrator.submitDecision as ReturnType<typeof mock>).mock.calls[0];
2144
+ expect(decisionArgs[1]).toBe('deny');
2145
+
2146
+ deliverSpy.mockRestore();
2147
+ approvalSpy.mockRestore();
2148
+ });
2149
+ });
2150
+
2077
2151
  // ═══════════════════════════════════════════════════════════════════════════
2078
2152
  // 21. Guardian decision scoping — callback for older run resolves correctly
2079
2153
  // ═══════════════════════════════════════════════════════════════════════════
@@ -2360,3 +2434,845 @@ describe('expired guardian approval auto-denies via sweep', () => {
2360
2434
  deliverSpy.mockRestore();
2361
2435
  });
2362
2436
  });
2437
+
2438
+ // ═══════════════════════════════════════════════════════════════════════════
2439
+ // 24. Deliver-once idempotency guard
2440
+ // ═══════════════════════════════════════════════════════════════════════════
2441
+
2442
+ describe('deliver-once idempotency guard', () => {
2443
+ test('claimRunDelivery returns true on first call, false on subsequent calls', () => {
2444
+ const runId = 'run-idem-unit';
2445
+ expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
2446
+ expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
2447
+ expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(false);
2448
+ channelDeliveryStore.resetRunDeliveryClaim(runId);
2449
+ });
2450
+
2451
+ test('different run IDs are independent', () => {
2452
+ expect(channelDeliveryStore.claimRunDelivery('run-a')).toBe(true);
2453
+ expect(channelDeliveryStore.claimRunDelivery('run-b')).toBe(true);
2454
+ expect(channelDeliveryStore.claimRunDelivery('run-a')).toBe(false);
2455
+ expect(channelDeliveryStore.claimRunDelivery('run-b')).toBe(false);
2456
+ channelDeliveryStore.resetRunDeliveryClaim('run-a');
2457
+ channelDeliveryStore.resetRunDeliveryClaim('run-b');
2458
+ });
2459
+
2460
+ test('resetRunDeliveryClaim allows re-claim', () => {
2461
+ const runId = 'run-idem-reset';
2462
+ expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
2463
+ channelDeliveryStore.resetRunDeliveryClaim(runId);
2464
+ expect(channelDeliveryStore.claimRunDelivery(runId)).toBe(true);
2465
+ channelDeliveryStore.resetRunDeliveryClaim(runId);
2466
+ });
2467
+ });
2468
+
2469
+ // ═══════════════════════════════════════════════════════════════════════════
2470
+ // 25. Final reply idempotency — main poll wins vs post-decision poll wins
2471
+ // ═══════════════════════════════════════════════════════════════════════════
2472
+
2473
+ describe('final reply idempotency — no duplicate delivery', () => {
2474
+ beforeEach(() => {
2475
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2476
+ });
2477
+
2478
+ test('main poll wins: deliverChannelReply called exactly once when main poll delivers first', async () => {
2479
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2480
+
2481
+ // Establish the conversation
2482
+ const initReq = makeInboundRequest({ content: 'init' });
2483
+ const orchestrator = makeMockOrchestrator();
2484
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
2485
+
2486
+ const db = getDb();
2487
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
2488
+ const conversationId = events[0]?.conversation_id;
2489
+ ensureConversation(conversationId!);
2490
+
2491
+ // Create a pending run and add an assistant message for delivery
2492
+ const run = createRun(conversationId!);
2493
+ setRunConfirmation(run.id, sampleConfirmation);
2494
+ conversationStore.addMessage(conversationId!, 'assistant', 'Main poll result.');
2495
+
2496
+ // Orchestrator: first getRun returns needs_confirmation (to trigger
2497
+ // approval prompt delivery in the poll), subsequent calls return
2498
+ // completed so the main poll can deliver the reply.
2499
+ let getRunCount = 0;
2500
+ const racingOrchestrator = {
2501
+ submitDecision: mock(() => 'applied' as const),
2502
+ getRun: mock(() => {
2503
+ getRunCount++;
2504
+ if (getRunCount <= 1) {
2505
+ return {
2506
+ id: run.id,
2507
+ conversationId: conversationId!,
2508
+ messageId: null,
2509
+ status: 'needs_confirmation' as const,
2510
+ pendingConfirmation: sampleConfirmation,
2511
+ pendingSecret: null,
2512
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2513
+ error: null,
2514
+ createdAt: Date.now(), updatedAt: Date.now(),
2515
+ };
2516
+ }
2517
+ return {
2518
+ id: run.id,
2519
+ conversationId: conversationId!,
2520
+ messageId: null,
2521
+ status: 'completed' as const,
2522
+ pendingConfirmation: null,
2523
+ pendingSecret: null,
2524
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2525
+ error: null,
2526
+ createdAt: Date.now(), updatedAt: Date.now(),
2527
+ };
2528
+ }),
2529
+ startRun: mock(async () => ({
2530
+ id: run.id,
2531
+ conversationId: conversationId!,
2532
+ messageId: null,
2533
+ status: 'running' as const,
2534
+ pendingConfirmation: null,
2535
+ pendingSecret: null,
2536
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2537
+ error: null,
2538
+ createdAt: Date.now(), updatedAt: Date.now(),
2539
+ })),
2540
+ } as unknown as RunOrchestrator;
2541
+
2542
+ deliverSpy.mockClear();
2543
+
2544
+ // Send a message that triggers the approval path, then send a decision
2545
+ // to trigger the post-decision poll. Both pollers should compete for delivery.
2546
+ const msgReq = makeInboundRequest({ content: 'do something' });
2547
+ await handleChannelInbound(msgReq, noopProcessMessage, 'token', racingOrchestrator);
2548
+
2549
+ // Send the decision to start the post-decision delivery poll
2550
+ const decisionReq = makeInboundRequest({
2551
+ content: '',
2552
+ callbackData: `apr:${run.id}:approve_once`,
2553
+ });
2554
+ await handleChannelInbound(decisionReq, noopProcessMessage, 'token', racingOrchestrator);
2555
+
2556
+ // Wait for both pollers to finish
2557
+ await new Promise((resolve) => setTimeout(resolve, 2000));
2558
+
2559
+ // Count deliverChannelReply calls that carry the assistant reply text.
2560
+ // Approval-related notifications (e.g. "has been sent to the guardian")
2561
+ // are separate from the final reply. The final reply call is the one
2562
+ // that delivers the actual conversation content.
2563
+ const replyDeliveryCalls = deliverSpy.mock.calls.filter(
2564
+ (call) => typeof call[1] === 'object' &&
2565
+ (call[1] as { text?: string }).text === 'Main poll result.',
2566
+ );
2567
+
2568
+ // The guard should ensure at most one delivery of the final reply
2569
+ expect(replyDeliveryCalls.length).toBeLessThanOrEqual(1);
2570
+
2571
+ deliverSpy.mockRestore();
2572
+ });
2573
+
2574
+ test('post-decision poll wins: delivers exactly once when main poll already exited', async () => {
2575
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2576
+
2577
+ // Establish the conversation
2578
+ const initReq = makeInboundRequest({ content: 'init-late' });
2579
+ const orchestrator = makeMockOrchestrator();
2580
+ await handleChannelInbound(initReq, noopProcessMessage, 'token', orchestrator);
2581
+
2582
+ const db = getDb();
2583
+ const events = db.$client.prepare('SELECT conversation_id FROM channel_inbound_events').all() as Array<{ conversation_id: string }>;
2584
+ const conversationId = events[events.length - 1]?.conversation_id;
2585
+ ensureConversation(conversationId!);
2586
+
2587
+ // Create a pending run
2588
+ const run = createRun(conversationId!);
2589
+ setRunConfirmation(run.id, sampleConfirmation);
2590
+ conversationStore.addMessage(conversationId!, 'assistant', 'Post-decision result.');
2591
+
2592
+ // Orchestrator: getRun always returns needs_confirmation for the main poll
2593
+ // (so the main poll times out without delivering), then returns completed
2594
+ // for the post-decision poll. We use a separate call counter per context.
2595
+ let mainPollExited = false;
2596
+ let postDecisionGetRunCount = 0;
2597
+ const lateOrchestrator = {
2598
+ submitDecision: mock(() => 'applied' as const),
2599
+ getRun: mock(() => {
2600
+ if (!mainPollExited) {
2601
+ // Main poll context — always return needs_confirmation so it exits
2602
+ // without delivering (the 5min timeout is simulated by having the
2603
+ // main poll see needs_confirmation until it gives up).
2604
+ return {
2605
+ id: run.id,
2606
+ conversationId: conversationId!,
2607
+ messageId: null,
2608
+ status: 'needs_confirmation' as const,
2609
+ pendingConfirmation: sampleConfirmation,
2610
+ pendingSecret: null,
2611
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2612
+ error: null,
2613
+ createdAt: Date.now(), updatedAt: Date.now(),
2614
+ };
2615
+ }
2616
+ // Post-decision poll — return completed after a short delay
2617
+ postDecisionGetRunCount++;
2618
+ if (postDecisionGetRunCount <= 1) {
2619
+ return {
2620
+ id: run.id,
2621
+ conversationId: conversationId!,
2622
+ messageId: null,
2623
+ status: 'needs_confirmation' as const,
2624
+ pendingConfirmation: null,
2625
+ pendingSecret: null,
2626
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2627
+ error: null,
2628
+ createdAt: Date.now(), updatedAt: Date.now(),
2629
+ };
2630
+ }
2631
+ return {
2632
+ id: run.id,
2633
+ conversationId: conversationId!,
2634
+ messageId: null,
2635
+ status: 'completed' as const,
2636
+ pendingConfirmation: null,
2637
+ pendingSecret: null,
2638
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2639
+ error: null,
2640
+ createdAt: Date.now(), updatedAt: Date.now(),
2641
+ };
2642
+ }),
2643
+ startRun: mock(async () => ({
2644
+ id: run.id,
2645
+ conversationId: conversationId!,
2646
+ messageId: null,
2647
+ status: 'running' as const,
2648
+ pendingConfirmation: null,
2649
+ pendingSecret: null,
2650
+ inputTokens: 0, outputTokens: 0, estimatedCost: 0,
2651
+ error: null,
2652
+ createdAt: Date.now(), updatedAt: Date.now(),
2653
+ })),
2654
+ } as unknown as RunOrchestrator;
2655
+
2656
+ deliverSpy.mockClear();
2657
+
2658
+ // Start the main poll — it will see needs_confirmation and exit after
2659
+ // the first poll interval (marking the event as processed, not delivering).
2660
+ const msgReq = makeInboundRequest({ content: 'do something late' });
2661
+ await handleChannelInbound(msgReq, noopProcessMessage, 'token', lateOrchestrator);
2662
+
2663
+ // Wait for the main poll to see needs_confirmation and mark processed
2664
+ await new Promise((resolve) => setTimeout(resolve, 800));
2665
+ mainPollExited = true;
2666
+
2667
+ // Now send the decision to trigger the post-decision delivery
2668
+ const decisionReq = makeInboundRequest({
2669
+ content: '',
2670
+ callbackData: `apr:${run.id}:approve_once`,
2671
+ });
2672
+ await handleChannelInbound(decisionReq, noopProcessMessage, 'token', lateOrchestrator);
2673
+
2674
+ // Wait for the post-decision poll to deliver
2675
+ await new Promise((resolve) => setTimeout(resolve, 1500));
2676
+
2677
+ // Count deliveries of the final assistant reply
2678
+ const replyDeliveryCalls = deliverSpy.mock.calls.filter(
2679
+ (call) => typeof call[1] === 'object' &&
2680
+ (call[1] as { text?: string }).text === 'Post-decision result.',
2681
+ );
2682
+
2683
+ // Exactly one delivery should have occurred (from the post-decision poll)
2684
+ expect(replyDeliveryCalls.length).toBe(1);
2685
+
2686
+ deliverSpy.mockRestore();
2687
+ });
2688
+ });
2689
+
2690
+ // ═══════════════════════════════════════════════════════════════════════════
2691
+ // 26. Assistant-scoped guardian verification via handleChannelInbound
2692
+ // ═══════════════════════════════════════════════════════════════════════════
2693
+
2694
+ describe('assistant-scoped guardian verification via handleChannelInbound', () => {
2695
+ test('/guardian_verify uses the threaded assistantId (default: self)', async () => {
2696
+ const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
2697
+ const { secret } = createVerificationChallenge('self', 'telegram');
2698
+
2699
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2700
+
2701
+ const req = makeInboundRequest({
2702
+ content: `/guardian_verify ${secret}`,
2703
+ senderExternalUserId: 'user-default-asst',
2704
+ });
2705
+
2706
+ // No assistantId passed => defaults to 'self'
2707
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token');
2708
+ const body = await res.json() as Record<string, unknown>;
2709
+
2710
+ expect(body.accepted).toBe(true);
2711
+ expect(body.guardianVerification).toBe('verified');
2712
+
2713
+ deliverSpy.mockRestore();
2714
+ });
2715
+
2716
+ test('/guardian_verify with explicit assistantId resolves against that assistant', async () => {
2717
+ const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
2718
+ const { getGuardianBinding } = await import('../runtime/channel-guardian-service.js');
2719
+
2720
+ // Create a challenge for asst-route-X
2721
+ const { secret } = createVerificationChallenge('asst-route-X', 'telegram');
2722
+
2723
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2724
+
2725
+ const req = makeInboundRequest({
2726
+ content: `/guardian_verify ${secret}`,
2727
+ senderExternalUserId: 'user-for-asst-x',
2728
+ });
2729
+
2730
+ // Pass assistantId = 'asst-route-X'
2731
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', undefined, 'asst-route-X');
2732
+ const body = await res.json() as Record<string, unknown>;
2733
+
2734
+ expect(body.accepted).toBe(true);
2735
+ expect(body.guardianVerification).toBe('verified');
2736
+
2737
+ // Binding should exist for asst-route-X, not for 'self'
2738
+ const bindingX = getGuardianBinding('asst-route-X', 'telegram');
2739
+ expect(bindingX).not.toBeNull();
2740
+ expect(bindingX!.guardianExternalUserId).toBe('user-for-asst-x');
2741
+
2742
+ deliverSpy.mockRestore();
2743
+ });
2744
+
2745
+ test('cross-assistant challenge verification fails', async () => {
2746
+ const { createVerificationChallenge } = await import('../runtime/channel-guardian-service.js');
2747
+
2748
+ // Create challenge for asst-A
2749
+ const { secret } = createVerificationChallenge('asst-A-cross', 'telegram');
2750
+
2751
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2752
+
2753
+ const req = makeInboundRequest({
2754
+ content: `/guardian_verify ${secret}`,
2755
+ senderExternalUserId: 'user-cross-test',
2756
+ });
2757
+
2758
+ // Try to verify using asst-B — should fail because the challenge is for asst-A
2759
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', undefined, 'asst-B-cross');
2760
+ const body = await res.json() as Record<string, unknown>;
2761
+
2762
+ expect(body.accepted).toBe(true);
2763
+ expect(body.guardianVerification).toBe('failed');
2764
+
2765
+ deliverSpy.mockRestore();
2766
+ });
2767
+
2768
+ test('actor role resolution uses threaded assistantId', async () => {
2769
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2770
+
2771
+ // Create guardian binding for asst-role-X
2772
+ createBinding({
2773
+ assistantId: 'asst-role-X',
2774
+ channel: 'telegram',
2775
+ guardianExternalUserId: 'guardian-role-user',
2776
+ guardianDeliveryChatId: 'guardian-role-chat',
2777
+ });
2778
+
2779
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2780
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2781
+
2782
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-role-scoped', terminalStatus: 'completed' });
2783
+
2784
+ // Non-guardian user sending to asst-role-X should be recognized as non-guardian
2785
+ const req = makeInboundRequest({
2786
+ content: 'do something dangerous',
2787
+ senderExternalUserId: 'non-guardian-role-user',
2788
+ });
2789
+
2790
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator, 'asst-role-X');
2791
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2792
+
2793
+ // The approval prompt should have been sent to the guardian's chat
2794
+ expect(approvalSpy).toHaveBeenCalled();
2795
+ const approvalArgs = approvalSpy.mock.calls[0];
2796
+ expect(approvalArgs[1]).toBe('guardian-role-chat');
2797
+
2798
+ deliverSpy.mockRestore();
2799
+ approvalSpy.mockRestore();
2800
+ });
2801
+
2802
+ test('same user is guardian for one assistant but not another', async () => {
2803
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2804
+
2805
+ // user-multi is guardian for asst-M1 but not asst-M2
2806
+ createBinding({
2807
+ assistantId: 'asst-M1',
2808
+ channel: 'telegram',
2809
+ guardianExternalUserId: 'user-multi',
2810
+ guardianDeliveryChatId: 'chat-multi',
2811
+ });
2812
+ createBinding({
2813
+ assistantId: 'asst-M2',
2814
+ channel: 'telegram',
2815
+ guardianExternalUserId: 'user-other-guardian',
2816
+ guardianDeliveryChatId: 'chat-other-guardian',
2817
+ });
2818
+
2819
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2820
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2821
+
2822
+ // For asst-M1: user-multi is the guardian, so should get standard self-approval
2823
+ const orch1 = makeSensitiveOrchestrator({ runId: 'run-m1', terminalStatus: 'completed' });
2824
+ const req1 = makeInboundRequest({
2825
+ content: 'dangerous action',
2826
+ senderExternalUserId: 'user-multi',
2827
+ });
2828
+
2829
+ await handleChannelInbound(req1, noopProcessMessage, 'token', orch1, 'asst-M1');
2830
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2831
+
2832
+ // For asst-M1, user-multi is guardian — approval prompt to own chat (standard flow)
2833
+ expect(approvalSpy).toHaveBeenCalled();
2834
+ const m1ApprovalArgs = approvalSpy.mock.calls[0];
2835
+ // Should be sent to user-multi's own chat (chat-123 from makeInboundRequest default)
2836
+ expect(m1ApprovalArgs[1]).toBe('chat-123');
2837
+
2838
+ approvalSpy.mockClear();
2839
+ deliverSpy.mockClear();
2840
+
2841
+ // For asst-M2: user-multi is NOT the guardian, so approval should route to asst-M2's guardian
2842
+ const orch2 = makeSensitiveOrchestrator({ runId: 'run-m2', terminalStatus: 'completed' });
2843
+ const req2 = makeInboundRequest({
2844
+ content: 'another dangerous action',
2845
+ senderExternalUserId: 'user-multi',
2846
+ });
2847
+
2848
+ await handleChannelInbound(req2, noopProcessMessage, 'token', orch2, 'asst-M2');
2849
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2850
+
2851
+ // For asst-M2, user-multi is non-guardian — approval should go to user-other-guardian's chat
2852
+ expect(approvalSpy).toHaveBeenCalled();
2853
+ const m2ApprovalArgs = approvalSpy.mock.calls[0];
2854
+ expect(m2ApprovalArgs[1]).toBe('chat-other-guardian');
2855
+
2856
+ deliverSpy.mockRestore();
2857
+ approvalSpy.mockRestore();
2858
+ });
2859
+ });
2860
+
2861
+ // ═══════════════════════════════════════════════════════════════════════════
2862
+ // 27. Guardian enforcement decoupled from CHANNEL_APPROVALS_ENABLED
2863
+ // ═══════════════════════════════════════════════════════════════════════════
2864
+
2865
+ describe('guardian enforcement independence from approval flag', () => {
2866
+ test('actor role resolution runs when CHANNEL_APPROVALS_ENABLED is off', async () => {
2867
+ delete process.env.CHANNEL_APPROVALS_ENABLED;
2868
+
2869
+ // Create a guardian binding — user-guardian is the guardian
2870
+ createBinding({
2871
+ assistantId: 'self',
2872
+ channel: 'telegram',
2873
+ guardianExternalUserId: 'user-guardian',
2874
+ guardianDeliveryChatId: 'chat-guardian',
2875
+ });
2876
+
2877
+ // A non-guardian user sends a message with approvals disabled.
2878
+ // Actor role resolution should still classify them as non-guardian
2879
+ // even though the approval UX is off.
2880
+ const orchestrator = makeMockOrchestrator();
2881
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2882
+
2883
+ const req = makeInboundRequest({
2884
+ content: 'hello world',
2885
+ senderExternalUserId: 'user-non-guardian',
2886
+ });
2887
+
2888
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2889
+ expect(res.status).toBe(200);
2890
+
2891
+ // The message should proceed normally since approval UX is off,
2892
+ // but actor role resolution still ran (verified by the fact that
2893
+ // the message processed successfully without error)
2894
+ const body = await res.json() as Record<string, unknown>;
2895
+ expect(body.accepted).toBe(true);
2896
+
2897
+ deliverSpy.mockRestore();
2898
+ });
2899
+
2900
+ test('missing senderExternalUserId with guardian binding fails closed', async () => {
2901
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2902
+
2903
+ // Create a guardian binding — guardian enforcement is active
2904
+ createBinding({
2905
+ assistantId: 'self',
2906
+ channel: 'telegram',
2907
+ guardianExternalUserId: 'user-guardian',
2908
+ guardianDeliveryChatId: 'chat-guardian',
2909
+ });
2910
+
2911
+ // Use makeSensitiveOrchestrator so that getRun returns needs_confirmation
2912
+ // on the first poll (triggering the unverified_channel auto-deny path)
2913
+ // and then returns terminal state.
2914
+ const orchestrator = makeSensitiveOrchestrator({ runId: 'run-failclosed-1', terminalStatus: 'failed' });
2915
+
2916
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2917
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
2918
+
2919
+ // Send a message WITHOUT senderExternalUserId
2920
+ const req = makeInboundRequest({
2921
+ content: 'do something dangerous',
2922
+ senderExternalUserId: undefined,
2923
+ });
2924
+
2925
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2926
+ expect(res.status).toBe(200);
2927
+
2928
+ // Wait for background processing
2929
+ await new Promise((resolve) => setTimeout(resolve, 1200));
2930
+
2931
+ // The unknown actor should be treated as unverified_channel and
2932
+ // sensitive actions should be auto-denied via the no_identity branch.
2933
+ // deliverChannelReply args: (callbackUrl, payload, bearerToken?)
2934
+ // The denial notice is in payload.text (index 1 of the call args).
2935
+ expect(deliverSpy).toHaveBeenCalled();
2936
+ const denialCalls = deliverSpy.mock.calls.filter(
2937
+ (call) => {
2938
+ if (typeof call[1] !== 'object') return false;
2939
+ const text = (call[1] as { text?: string }).text ?? '';
2940
+ return text.includes('requires guardian approval') &&
2941
+ (text.includes('identity could not be determined') || text.includes('no guardian has been set up'));
2942
+ },
2943
+ );
2944
+ expect(denialCalls.length).toBeGreaterThanOrEqual(1);
2945
+
2946
+ // Auto-deny path should never prompt for approval
2947
+ expect(approvalSpy).not.toHaveBeenCalled();
2948
+
2949
+ deliverSpy.mockRestore();
2950
+ approvalSpy.mockRestore();
2951
+ });
2952
+
2953
+ test('missing senderExternalUserId without guardian binding uses default flow', async () => {
2954
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
2955
+
2956
+ // No guardian binding exists — default behavior should be preserved
2957
+ const orchestrator = makeMockOrchestrator();
2958
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
2959
+
2960
+ const req = makeInboundRequest({
2961
+ content: 'hello world',
2962
+ senderExternalUserId: undefined,
2963
+ });
2964
+
2965
+ const res = await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
2966
+ expect(res.status).toBe(200);
2967
+
2968
+ const body = await res.json() as Record<string, unknown>;
2969
+ expect(body.accepted).toBe(true);
2970
+
2971
+ deliverSpy.mockRestore();
2972
+ });
2973
+ });
2974
+
2975
+ // ═══════════════════════════════════════════════════════════════════════════
2976
+ // 28. Gateway-origin proof hardening — dedicated secret support
2977
+ // ═══════════════════════════════════════════════════════════════════════════
2978
+
2979
+ describe('verifyGatewayOrigin with dedicated gateway-origin secret', () => {
2980
+ function makeReqWithHeader(value?: string): Request {
2981
+ const headers: Record<string, string> = { 'Content-Type': 'application/json' };
2982
+ if (value !== undefined) {
2983
+ headers['X-Gateway-Origin'] = value;
2984
+ }
2985
+ return new Request('http://localhost/channels/inbound', {
2986
+ method: 'POST',
2987
+ headers,
2988
+ body: '{}',
2989
+ });
2990
+ }
2991
+
2992
+ test('returns true when no secrets configured (local dev)', () => {
2993
+ expect(verifyGatewayOrigin(makeReqWithHeader(), undefined, undefined)).toBe(true);
2994
+ });
2995
+
2996
+ test('falls back to bearerToken when no dedicated secret is set', () => {
2997
+ expect(verifyGatewayOrigin(makeReqWithHeader('my-bearer'), 'my-bearer', undefined)).toBe(true);
2998
+ expect(verifyGatewayOrigin(makeReqWithHeader('wrong'), 'my-bearer', undefined)).toBe(false);
2999
+ expect(verifyGatewayOrigin(makeReqWithHeader(), 'my-bearer', undefined)).toBe(false);
3000
+ });
3001
+
3002
+ test('uses dedicated secret when set, ignoring bearer token', () => {
3003
+ // Dedicated secret matches — should pass even if bearer token differs
3004
+ expect(verifyGatewayOrigin(makeReqWithHeader('dedicated-secret'), 'bearer-token', 'dedicated-secret')).toBe(true);
3005
+ // Bearer token matches but dedicated secret doesn't — should fail
3006
+ expect(verifyGatewayOrigin(makeReqWithHeader('bearer-token'), 'bearer-token', 'dedicated-secret')).toBe(false);
3007
+ });
3008
+
3009
+ test('validates dedicated secret even when bearer token is not configured', () => {
3010
+ // No bearer token but dedicated secret is set — should validate against it
3011
+ expect(verifyGatewayOrigin(makeReqWithHeader('my-secret'), undefined, 'my-secret')).toBe(true);
3012
+ expect(verifyGatewayOrigin(makeReqWithHeader('wrong'), undefined, 'my-secret')).toBe(false);
3013
+ });
3014
+
3015
+ test('rejects missing header when any secret is configured', () => {
3016
+ expect(verifyGatewayOrigin(makeReqWithHeader(), 'bearer', undefined)).toBe(false);
3017
+ expect(verifyGatewayOrigin(makeReqWithHeader(), undefined, 'secret')).toBe(false);
3018
+ expect(verifyGatewayOrigin(makeReqWithHeader(), 'bearer', 'secret')).toBe(false);
3019
+ });
3020
+
3021
+ test('rejects mismatched length headers (constant-time comparison guard)', () => {
3022
+ // Different lengths should be rejected without timing leaks
3023
+ expect(verifyGatewayOrigin(makeReqWithHeader('short'), 'a-much-longer-secret', undefined)).toBe(false);
3024
+ expect(verifyGatewayOrigin(makeReqWithHeader('a-much-longer-secret'), 'short', undefined)).toBe(false);
3025
+ });
3026
+ });
3027
+
3028
+ // ═══════════════════════════════════════════════════════════════════════════
3029
+ // 29. handleChannelInbound passes gatewayOriginSecret to verifyGatewayOrigin
3030
+ // ═══════════════════════════════════════════════════════════════════════════
3031
+
3032
+ describe('handleChannelInbound gatewayOriginSecret integration', () => {
3033
+ test('rejects request when bearer token matches but dedicated secret does not', async () => {
3034
+ const bearerToken = 'my-bearer';
3035
+ const gatewaySecret = 'dedicated-gw-secret';
3036
+
3037
+ // Request carries the bearer token as X-Gateway-Origin, but the
3038
+ // dedicated secret is configured — verifyGatewayOrigin should require
3039
+ // the dedicated secret, not the bearer token.
3040
+ const req = new Request('http://localhost/channels/inbound', {
3041
+ method: 'POST',
3042
+ headers: {
3043
+ 'Content-Type': 'application/json',
3044
+ 'X-Gateway-Origin': bearerToken,
3045
+ },
3046
+ body: JSON.stringify({
3047
+ sourceChannel: 'telegram',
3048
+ externalChatId: 'chat-gw-secret-test',
3049
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
3050
+ content: 'hello',
3051
+ }),
3052
+ });
3053
+
3054
+ const res = await handleChannelInbound(
3055
+ req, noopProcessMessage, bearerToken, undefined, 'self', gatewaySecret,
3056
+ );
3057
+ expect(res.status).toBe(403);
3058
+ const body = await res.json() as { code: string };
3059
+ expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
3060
+ });
3061
+
3062
+ test('accepts request when dedicated secret matches', async () => {
3063
+ const bearerToken = 'my-bearer';
3064
+ const gatewaySecret = 'dedicated-gw-secret';
3065
+
3066
+ const req = new Request('http://localhost/channels/inbound', {
3067
+ method: 'POST',
3068
+ headers: {
3069
+ 'Content-Type': 'application/json',
3070
+ 'X-Gateway-Origin': gatewaySecret,
3071
+ },
3072
+ body: JSON.stringify({
3073
+ sourceChannel: 'telegram',
3074
+ externalChatId: 'chat-gw-secret-pass',
3075
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
3076
+ content: 'hello',
3077
+ }),
3078
+ });
3079
+
3080
+ const res = await handleChannelInbound(
3081
+ req, noopProcessMessage, bearerToken, undefined, 'self', gatewaySecret,
3082
+ );
3083
+ // Should pass the gateway-origin check and proceed to normal processing
3084
+ expect(res.status).toBe(200);
3085
+ const body = await res.json() as Record<string, unknown>;
3086
+ expect(body.accepted).toBe(true);
3087
+ });
3088
+
3089
+ test('falls back to bearer token when no dedicated secret is set', async () => {
3090
+ const bearerToken = 'my-bearer';
3091
+
3092
+ const req = new Request('http://localhost/channels/inbound', {
3093
+ method: 'POST',
3094
+ headers: {
3095
+ 'Content-Type': 'application/json',
3096
+ 'X-Gateway-Origin': bearerToken,
3097
+ },
3098
+ body: JSON.stringify({
3099
+ sourceChannel: 'telegram',
3100
+ externalChatId: 'chat-gw-fallback',
3101
+ externalMessageId: `msg-${Date.now()}-${Math.random()}`,
3102
+ content: 'hello',
3103
+ }),
3104
+ });
3105
+
3106
+ // No gatewayOriginSecret (6th param undefined) — should fall back to bearer
3107
+ const res = await handleChannelInbound(
3108
+ req, noopProcessMessage, bearerToken, undefined, 'self', undefined,
3109
+ );
3110
+ expect(res.status).toBe(200);
3111
+ const body = await res.json() as Record<string, unknown>;
3112
+ expect(body.accepted).toBe(true);
3113
+ });
3114
+ });
3115
+
3116
+ // ═══════════════════════════════════════════════════════════════════════════
3117
+ // 30. Unknown actor identity — forceStrictSideEffects propagation
3118
+ // ═══════════════════════════════════════════════════════════════════════════
3119
+
3120
+ describe('unknown actor identity — forceStrictSideEffects', () => {
3121
+ beforeEach(() => {
3122
+ process.env.CHANNEL_APPROVALS_ENABLED = 'true';
3123
+ });
3124
+
3125
+ test('unknown sender (no senderExternalUserId) with guardian binding gets forceStrictSideEffects', async () => {
3126
+ // Create a guardian binding so the channel is guardian-enforced
3127
+ createBinding({
3128
+ assistantId: 'self',
3129
+ channel: 'telegram',
3130
+ guardianExternalUserId: 'known-guardian',
3131
+ guardianDeliveryChatId: 'guardian-chat',
3132
+ });
3133
+
3134
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3135
+
3136
+ const mockRun = {
3137
+ id: 'run-unknown-actor',
3138
+ conversationId: 'conv-1',
3139
+ messageId: null,
3140
+ status: 'running' as const,
3141
+ pendingConfirmation: null,
3142
+ pendingSecret: null,
3143
+ inputTokens: 0,
3144
+ outputTokens: 0,
3145
+ estimatedCost: 0,
3146
+ error: null,
3147
+ createdAt: Date.now(),
3148
+ updatedAt: Date.now(),
3149
+ };
3150
+
3151
+ const orchestrator = {
3152
+ submitDecision: mock(() => 'applied' as const),
3153
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
3154
+ startRun: mock(async () => mockRun),
3155
+ } as unknown as RunOrchestrator;
3156
+
3157
+ // Send message with no senderExternalUserId — the unknown actor should
3158
+ // be classified as unverified_channel and forceStrictSideEffects set.
3159
+ const req = makeInboundRequest({
3160
+ content: 'do something',
3161
+ senderExternalUserId: undefined,
3162
+ });
3163
+
3164
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
3165
+ await new Promise((resolve) => setTimeout(resolve, 800));
3166
+
3167
+ // startRun should have been called with forceStrictSideEffects: true
3168
+ expect(orchestrator.startRun).toHaveBeenCalled();
3169
+ const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
3170
+ const options = startRunArgs[3] as { forceStrictSideEffects?: boolean } | undefined;
3171
+ expect(options).toBeDefined();
3172
+ expect(options!.forceStrictSideEffects).toBe(true);
3173
+
3174
+ deliverSpy.mockRestore();
3175
+ });
3176
+
3177
+ test('known non-guardian sender with guardian binding gets forceStrictSideEffects', async () => {
3178
+ createBinding({
3179
+ assistantId: 'self',
3180
+ channel: 'telegram',
3181
+ guardianExternalUserId: 'the-guardian',
3182
+ guardianDeliveryChatId: 'guardian-chat-2',
3183
+ });
3184
+
3185
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3186
+ const approvalSpy = spyOn(gatewayClient, 'deliverApprovalPrompt').mockResolvedValue(undefined);
3187
+
3188
+ const mockRun = {
3189
+ id: 'run-nongrd-strict',
3190
+ conversationId: 'conv-1',
3191
+ messageId: null,
3192
+ status: 'running' as const,
3193
+ pendingConfirmation: null,
3194
+ pendingSecret: null,
3195
+ inputTokens: 0,
3196
+ outputTokens: 0,
3197
+ estimatedCost: 0,
3198
+ error: null,
3199
+ createdAt: Date.now(),
3200
+ updatedAt: Date.now(),
3201
+ };
3202
+
3203
+ const orchestrator = {
3204
+ submitDecision: mock(() => 'applied' as const),
3205
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
3206
+ startRun: mock(async () => mockRun),
3207
+ } as unknown as RunOrchestrator;
3208
+
3209
+ // Non-guardian user sends a message
3210
+ const req = makeInboundRequest({
3211
+ content: 'do something',
3212
+ senderExternalUserId: 'not-the-guardian',
3213
+ });
3214
+
3215
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
3216
+ await new Promise((resolve) => setTimeout(resolve, 800));
3217
+
3218
+ // startRun should have been called with forceStrictSideEffects: true
3219
+ expect(orchestrator.startRun).toHaveBeenCalled();
3220
+ const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
3221
+ const options = startRunArgs[3] as { forceStrictSideEffects?: boolean } | undefined;
3222
+ expect(options).toBeDefined();
3223
+ expect(options!.forceStrictSideEffects).toBe(true);
3224
+
3225
+ deliverSpy.mockRestore();
3226
+ approvalSpy.mockRestore();
3227
+ });
3228
+
3229
+ test('guardian sender does NOT get forceStrictSideEffects', async () => {
3230
+ createBinding({
3231
+ assistantId: 'self',
3232
+ channel: 'telegram',
3233
+ guardianExternalUserId: 'the-guardian',
3234
+ guardianDeliveryChatId: 'guardian-chat-3',
3235
+ });
3236
+
3237
+ const deliverSpy = spyOn(gatewayClient, 'deliverChannelReply').mockResolvedValue(undefined);
3238
+
3239
+ const mockRun = {
3240
+ id: 'run-grd-no-strict',
3241
+ conversationId: 'conv-1',
3242
+ messageId: null,
3243
+ status: 'running' as const,
3244
+ pendingConfirmation: null,
3245
+ pendingSecret: null,
3246
+ inputTokens: 0,
3247
+ outputTokens: 0,
3248
+ estimatedCost: 0,
3249
+ error: null,
3250
+ createdAt: Date.now(),
3251
+ updatedAt: Date.now(),
3252
+ };
3253
+
3254
+ const orchestrator = {
3255
+ submitDecision: mock(() => 'applied' as const),
3256
+ getRun: mock(() => ({ ...mockRun, status: 'completed' as const })),
3257
+ startRun: mock(async () => mockRun),
3258
+ } as unknown as RunOrchestrator;
3259
+
3260
+ // The guardian sends a message — should NOT get forceStrictSideEffects
3261
+ const req = makeInboundRequest({
3262
+ content: 'do something',
3263
+ senderExternalUserId: 'the-guardian',
3264
+ });
3265
+
3266
+ await handleChannelInbound(req, noopProcessMessage, 'token', orchestrator);
3267
+ await new Promise((resolve) => setTimeout(resolve, 800));
3268
+
3269
+ expect(orchestrator.startRun).toHaveBeenCalled();
3270
+ const startRunArgs = (orchestrator.startRun as ReturnType<typeof mock>).mock.calls[0];
3271
+ const options = startRunArgs[3] as { forceStrictSideEffects?: boolean; sourceChannel?: string } | undefined;
3272
+ expect(options).toBeDefined();
3273
+ // Guardian should NOT have forceStrictSideEffects set
3274
+ expect(options!.forceStrictSideEffects).toBeUndefined();
3275
+
3276
+ deliverSpy.mockRestore();
3277
+ });
3278
+ });