@vellumai/assistant 0.3.3 → 0.3.5

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 (163) hide show
  1. package/Dockerfile +2 -0
  2. package/README.md +45 -18
  3. package/package.json +1 -1
  4. package/scripts/ipc/generate-swift.ts +13 -0
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +100 -0
  6. package/src/__tests__/approval-hardcoded-copy-guard.test.ts +41 -0
  7. package/src/__tests__/approval-message-composer.test.ts +253 -0
  8. package/src/__tests__/call-domain.test.ts +12 -2
  9. package/src/__tests__/call-orchestrator.test.ts +391 -1
  10. package/src/__tests__/call-routes-http.test.ts +27 -2
  11. package/src/__tests__/channel-approval-routes.test.ts +397 -135
  12. package/src/__tests__/channel-approvals.test.ts +99 -3
  13. package/src/__tests__/channel-delivery-store.test.ts +30 -4
  14. package/src/__tests__/channel-guardian.test.ts +261 -22
  15. package/src/__tests__/channel-readiness-service.test.ts +257 -0
  16. package/src/__tests__/config-schema.test.ts +2 -1
  17. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  18. package/src/__tests__/daemon-lifecycle.test.ts +636 -0
  19. package/src/__tests__/dictation-mode-detection.test.ts +63 -0
  20. package/src/__tests__/entity-search.test.ts +615 -0
  21. package/src/__tests__/gateway-only-enforcement.test.ts +19 -13
  22. package/src/__tests__/handlers-twilio-config.test.ts +480 -0
  23. package/src/__tests__/ipc-snapshot.test.ts +63 -0
  24. package/src/__tests__/messaging-send-tool.test.ts +65 -0
  25. package/src/__tests__/run-orchestrator-assistant-events.test.ts +4 -0
  26. package/src/__tests__/run-orchestrator.test.ts +22 -0
  27. package/src/__tests__/secret-scanner.test.ts +223 -0
  28. package/src/__tests__/session-runtime-assembly.test.ts +85 -1
  29. package/src/__tests__/shell-parser-property.test.ts +357 -2
  30. package/src/__tests__/sms-messaging-provider.test.ts +125 -0
  31. package/src/__tests__/system-prompt.test.ts +25 -1
  32. package/src/__tests__/tool-executor-lifecycle-events.test.ts +34 -1
  33. package/src/__tests__/twilio-routes.test.ts +39 -3
  34. package/src/__tests__/twitter-cli-error-shaping.test.ts +2 -2
  35. package/src/__tests__/user-reference.test.ts +68 -0
  36. package/src/__tests__/web-search.test.ts +1 -1
  37. package/src/__tests__/work-item-output.test.ts +110 -0
  38. package/src/calls/call-domain.ts +8 -5
  39. package/src/calls/call-orchestrator.ts +85 -22
  40. package/src/calls/twilio-config.ts +17 -11
  41. package/src/calls/twilio-rest.ts +276 -0
  42. package/src/calls/twilio-routes.ts +39 -1
  43. package/src/cli/map.ts +6 -0
  44. package/src/commands/__tests__/cc-command-registry.test.ts +67 -0
  45. package/src/commands/cc-command-registry.ts +14 -1
  46. package/src/config/bundled-skills/claude-code/TOOLS.json +10 -3
  47. package/src/config/bundled-skills/knowledge-graph/SKILL.md +15 -0
  48. package/src/config/bundled-skills/knowledge-graph/TOOLS.json +56 -0
  49. package/src/config/bundled-skills/knowledge-graph/tools/graph-query.ts +185 -0
  50. package/src/config/bundled-skills/media-processing/SKILL.md +199 -0
  51. package/src/config/bundled-skills/media-processing/TOOLS.json +320 -0
  52. package/src/config/bundled-skills/media-processing/services/capability-registry.ts +137 -0
  53. package/src/config/bundled-skills/media-processing/services/event-detection-service.ts +280 -0
  54. package/src/config/bundled-skills/media-processing/services/feedback-aggregation.ts +144 -0
  55. package/src/config/bundled-skills/media-processing/services/feedback-store.ts +136 -0
  56. package/src/config/bundled-skills/media-processing/services/processing-pipeline.ts +261 -0
  57. package/src/config/bundled-skills/media-processing/services/retrieval-service.ts +95 -0
  58. package/src/config/bundled-skills/media-processing/services/timeline-service.ts +267 -0
  59. package/src/config/bundled-skills/media-processing/tools/analyze-keyframes.ts +301 -0
  60. package/src/config/bundled-skills/media-processing/tools/detect-events.ts +110 -0
  61. package/src/config/bundled-skills/media-processing/tools/extract-keyframes.ts +190 -0
  62. package/src/config/bundled-skills/media-processing/tools/generate-clip.ts +195 -0
  63. package/src/config/bundled-skills/media-processing/tools/ingest-media.ts +197 -0
  64. package/src/config/bundled-skills/media-processing/tools/media-diagnostics.ts +166 -0
  65. package/src/config/bundled-skills/media-processing/tools/media-status.ts +75 -0
  66. package/src/config/bundled-skills/media-processing/tools/query-media-events.ts +300 -0
  67. package/src/config/bundled-skills/media-processing/tools/recalibrate.ts +235 -0
  68. package/src/config/bundled-skills/media-processing/tools/select-tracking-profile.ts +142 -0
  69. package/src/config/bundled-skills/media-processing/tools/submit-feedback.ts +150 -0
  70. package/src/config/bundled-skills/messaging/SKILL.md +24 -5
  71. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  72. package/src/config/bundled-skills/phone-calls/SKILL.md +2 -2
  73. package/src/config/bundled-skills/twitter/SKILL.md +19 -3
  74. package/src/config/defaults.ts +2 -1
  75. package/src/config/schema.ts +9 -3
  76. package/src/config/skills.ts +5 -32
  77. package/src/config/system-prompt.ts +40 -0
  78. package/src/config/templates/IDENTITY.md +2 -2
  79. package/src/config/user-reference.ts +29 -0
  80. package/src/config/vellum-skills/catalog.json +58 -0
  81. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +3 -3
  82. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +3 -3
  83. package/src/config/vellum-skills/sms-setup/SKILL.md +118 -0
  84. package/src/config/vellum-skills/telegram-setup/SKILL.md +6 -1
  85. package/src/config/vellum-skills/twilio-setup/SKILL.md +76 -6
  86. package/src/daemon/auth-manager.ts +103 -0
  87. package/src/daemon/computer-use-session.ts +8 -1
  88. package/src/daemon/config-watcher.ts +253 -0
  89. package/src/daemon/handlers/config.ts +819 -22
  90. package/src/daemon/handlers/dictation.ts +182 -0
  91. package/src/daemon/handlers/identity.ts +14 -23
  92. package/src/daemon/handlers/index.ts +2 -0
  93. package/src/daemon/handlers/sessions.ts +2 -0
  94. package/src/daemon/handlers/shared.ts +3 -0
  95. package/src/daemon/handlers/skills.ts +6 -7
  96. package/src/daemon/handlers/work-items.ts +15 -7
  97. package/src/daemon/ipc-contract-inventory.json +10 -0
  98. package/src/daemon/ipc-contract.ts +114 -4
  99. package/src/daemon/ipc-handler.ts +87 -0
  100. package/src/daemon/lifecycle.ts +18 -4
  101. package/src/daemon/ride-shotgun-handler.ts +11 -1
  102. package/src/daemon/server.ts +111 -504
  103. package/src/daemon/session-agent-loop.ts +10 -15
  104. package/src/daemon/session-runtime-assembly.ts +115 -44
  105. package/src/daemon/session-tool-setup.ts +2 -0
  106. package/src/daemon/session.ts +19 -2
  107. package/src/inbound/public-ingress-urls.ts +3 -3
  108. package/src/memory/channel-guardian-store.ts +2 -1
  109. package/src/memory/db-connection.ts +28 -0
  110. package/src/memory/db-init.ts +1163 -0
  111. package/src/memory/db.ts +2 -2007
  112. package/src/memory/embedding-backend.ts +79 -11
  113. package/src/memory/indexer.ts +2 -0
  114. package/src/memory/job-handlers/media-processing.ts +100 -0
  115. package/src/memory/job-utils.ts +64 -4
  116. package/src/memory/jobs-store.ts +2 -1
  117. package/src/memory/jobs-worker.ts +11 -1
  118. package/src/memory/media-store.ts +759 -0
  119. package/src/memory/recall-cache.ts +107 -0
  120. package/src/memory/retriever.ts +36 -2
  121. package/src/memory/schema-migration.ts +984 -0
  122. package/src/memory/schema.ts +99 -0
  123. package/src/memory/search/entity.ts +208 -25
  124. package/src/memory/search/ranking.ts +6 -1
  125. package/src/memory/search/types.ts +26 -0
  126. package/src/messaging/provider-types.ts +2 -0
  127. package/src/messaging/providers/sms/adapter.ts +204 -0
  128. package/src/messaging/providers/sms/client.ts +93 -0
  129. package/src/messaging/providers/sms/types.ts +7 -0
  130. package/src/permissions/checker.ts +16 -2
  131. package/src/permissions/prompter.ts +14 -3
  132. package/src/permissions/trust-store.ts +7 -0
  133. package/src/runtime/approval-message-composer.ts +143 -0
  134. package/src/runtime/channel-approvals.ts +29 -7
  135. package/src/runtime/channel-guardian-service.ts +44 -18
  136. package/src/runtime/channel-readiness-service.ts +292 -0
  137. package/src/runtime/channel-readiness-types.ts +29 -0
  138. package/src/runtime/gateway-client.ts +2 -1
  139. package/src/runtime/http-server.ts +65 -28
  140. package/src/runtime/http-types.ts +3 -0
  141. package/src/runtime/routes/call-routes.ts +2 -1
  142. package/src/runtime/routes/channel-routes.ts +237 -103
  143. package/src/runtime/routes/run-routes.ts +7 -1
  144. package/src/runtime/run-orchestrator.ts +43 -3
  145. package/src/security/secret-scanner.ts +218 -0
  146. package/src/skills/frontmatter.ts +63 -0
  147. package/src/skills/slash-commands.ts +23 -0
  148. package/src/skills/vellum-catalog-remote.ts +107 -0
  149. package/src/tools/assets/materialize.ts +2 -2
  150. package/src/tools/browser/auto-navigate.ts +132 -24
  151. package/src/tools/browser/browser-manager.ts +67 -61
  152. package/src/tools/calls/call-start.ts +1 -0
  153. package/src/tools/claude-code/claude-code.ts +55 -3
  154. package/src/tools/credentials/vault.ts +1 -1
  155. package/src/tools/execution-target.ts +11 -1
  156. package/src/tools/executor.ts +10 -2
  157. package/src/tools/network/web-search.ts +1 -1
  158. package/src/tools/skills/vellum-catalog.ts +61 -156
  159. package/src/tools/terminal/parser.ts +21 -5
  160. package/src/tools/types.ts +2 -0
  161. package/src/twitter/router.ts +1 -1
  162. package/src/util/platform.ts +43 -1
  163. package/src/util/retry.ts +4 -4
@@ -41,6 +41,7 @@ import {
41
41
  buildApprovalUIMetadata,
42
42
  handleChannelDecision,
43
43
  buildReminderPrompt,
44
+ buildGuardianApprovalPrompt,
44
45
  channelSupportsRichApprovalUI,
45
46
  } from '../runtime/channel-approvals.js';
46
47
  import type { ApprovalDecisionResult, ChannelApprovalPrompt } from '../runtime/channel-approval-types.js';
@@ -298,6 +299,53 @@ describe('handleChannelDecision', () => {
298
299
  expect(orchestrator.submitDecision).toHaveBeenCalledWith(run.id, 'deny');
299
300
  });
300
301
 
302
+ test('uses decision.runId to target the matching pending run', () => {
303
+ ensureConversation('conv-1');
304
+ const olderRun = createRun('conv-1');
305
+ setRunConfirmation(olderRun.id, {
306
+ ...sampleConfirmation,
307
+ toolName: 'shell',
308
+ toolUseId: 'req-older',
309
+ });
310
+ const newerRun = createRun('conv-1');
311
+ setRunConfirmation(newerRun.id, {
312
+ ...sampleConfirmation,
313
+ toolName: 'browser',
314
+ toolUseId: 'req-newer',
315
+ });
316
+
317
+ const orchestrator = makeMockOrchestrator();
318
+ const decision: ApprovalDecisionResult = {
319
+ action: 'approve_once',
320
+ source: 'telegram_button',
321
+ runId: newerRun.id,
322
+ };
323
+
324
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
325
+ expect(result.applied).toBe(true);
326
+ expect(result.runId).toBe(newerRun.id);
327
+ expect(orchestrator.submitDecision).toHaveBeenCalledWith(newerRun.id, 'allow');
328
+ expect(orchestrator.submitDecision).not.toHaveBeenCalledWith(olderRun.id, 'allow');
329
+ });
330
+
331
+ test('returns applied: false when decision.runId does not match a pending run', () => {
332
+ ensureConversation('conv-1');
333
+ const run = createRun('conv-1');
334
+ setRunConfirmation(run.id, sampleConfirmation);
335
+
336
+ const orchestrator = makeMockOrchestrator();
337
+ const decision: ApprovalDecisionResult = {
338
+ action: 'approve_once',
339
+ source: 'telegram_button',
340
+ runId: 'run-missing',
341
+ };
342
+
343
+ const result = handleChannelDecision('conv-1', decision, orchestrator);
344
+ expect(result.applied).toBe(false);
345
+ expect(result.runId).toBeUndefined();
346
+ expect(orchestrator.submitDecision).not.toHaveBeenCalled();
347
+ });
348
+
301
349
  test('approve_always adds a trust rule and submits "allow"', () => {
302
350
  ensureConversation('conv-1');
303
351
  const run = createRun('conv-1');
@@ -319,14 +367,16 @@ describe('handleChannelDecision', () => {
319
367
  expect(result.applied).toBe(true);
320
368
  expect(result.runId).toBe(run.id);
321
369
 
322
- // Trust rule added with first allowlist and scope option
370
+ // Trust rule added with first allowlist and scope option.
371
+ // executionTarget is undefined for core tools like 'shell' — only
372
+ // skill-origin tools persist it (see channel-approvals.ts).
323
373
  expect(addRuleSpy).toHaveBeenCalledWith(
324
374
  'shell',
325
375
  'rm -rf *',
326
376
  '/tmp/project',
327
377
  'allow',
328
378
  100,
329
- { executionTarget: 'sandbox' },
379
+ { executionTarget: undefined },
330
380
  );
331
381
 
332
382
  // The run is still approved with a simple "allow"
@@ -498,7 +548,53 @@ describe('buildReminderPrompt', () => {
498
548
  });
499
549
 
500
550
  // ═══════════════════════════════════════════════════════════════════════════
501
- // 5. channelSupportsRichApprovalUI
551
+ // 5. buildGuardianApprovalPrompt
552
+ // ═══════════════════════════════════════════════════════════════════════════
553
+
554
+ describe('buildGuardianApprovalPrompt', () => {
555
+ test('prompt includes requester identifier and tool name', () => {
556
+ const runInfo: PendingRunInfo = {
557
+ runId: 'run-g1',
558
+ requestId: 'req-g1',
559
+ toolName: 'deploy',
560
+ input: {},
561
+ riskLevel: 'high',
562
+ };
563
+ const prompt = buildGuardianApprovalPrompt(runInfo, 'alice');
564
+ expect(prompt.promptText).toContain('alice');
565
+ expect(prompt.promptText).toContain('deploy');
566
+ });
567
+
568
+ test('excludes approve_always action', () => {
569
+ const runInfo: PendingRunInfo = {
570
+ runId: 'run-g2',
571
+ requestId: 'req-g2',
572
+ toolName: 'shell',
573
+ input: {},
574
+ riskLevel: 'medium',
575
+ };
576
+ const prompt = buildGuardianApprovalPrompt(runInfo, 'bob');
577
+ expect(prompt.actions.map((a) => a.id)).not.toContain('approve_always');
578
+ expect(prompt.actions.map((a) => a.id)).toContain('approve_once');
579
+ expect(prompt.actions.map((a) => a.id)).toContain('reject');
580
+ });
581
+
582
+ test('plainTextFallback contains parser-compatible keywords', () => {
583
+ const runInfo: PendingRunInfo = {
584
+ runId: 'run-g3',
585
+ requestId: 'req-g3',
586
+ toolName: 'write_file',
587
+ input: {},
588
+ riskLevel: 'high',
589
+ };
590
+ const prompt = buildGuardianApprovalPrompt(runInfo, 'charlie');
591
+ expect(prompt.plainTextFallback).toContain('yes');
592
+ expect(prompt.plainTextFallback).toContain('no');
593
+ });
594
+ });
595
+
596
+ // ═══════════════════════════════════════════════════════════════════════════
597
+ // 6. channelSupportsRichApprovalUI
502
598
  // ═══════════════════════════════════════════════════════════════════════════
503
599
 
504
600
  describe('channelSupportsRichApprovalUI', () => {
@@ -24,7 +24,7 @@ mock.module('../util/logger.js', () => ({
24
24
  }));
25
25
 
26
26
  import { initializeDb, getDb, resetDb } from '../memory/db.js';
27
- import { channelInboundEvents, conversations, messages } from '../memory/schema.js';
27
+ import { channelInboundEvents, conversations, externalConversationBindings, messages } from '../memory/schema.js';
28
28
  import {
29
29
  recordInbound,
30
30
  linkMessage,
@@ -470,7 +470,7 @@ describe('channel-delivery-store', () => {
470
470
 
471
471
  // ── handleDeleteConversation assistantId parameter ───────────────
472
472
 
473
- test('handleDeleteConversation uses route assistantId param to delete scoped key', async () => {
473
+ test('handleDeleteConversation with non-self assistant deletes only scoped key', async () => {
474
474
  // Set up a scoped conversation key like the one created by recordInbound
475
475
  // with a specific assistantId.
476
476
  const convId = 'conv-delete-test';
@@ -488,6 +488,13 @@ describe('channel-delivery-store', () => {
488
488
  }).run();
489
489
  setConversationKey(scopedKey, convId);
490
490
  setConversationKey(legacyKey, convId);
491
+ db.insert(externalConversationBindings).values({
492
+ conversationId: convId,
493
+ sourceChannel: 'telegram',
494
+ externalChatId: 'chat-del',
495
+ createdAt: now,
496
+ updatedAt: now,
497
+ }).run();
491
498
 
492
499
  // Verify both keys exist
493
500
  expect(getConversationByKey(scopedKey)).not.toBeNull();
@@ -510,9 +517,15 @@ describe('channel-delivery-store', () => {
510
517
  const json = await res.json() as { ok: boolean };
511
518
  expect(json.ok).toBe(true);
512
519
 
513
- // Both the legacy key and the scoped key should be deleted
520
+ // Non-self delete should only remove the scoped key and preserve legacy.
514
521
  expect(getConversationByKey(scopedKey)).toBeNull();
515
- expect(getConversationByKey(legacyKey)).toBeNull();
522
+ expect(getConversationByKey(legacyKey)).not.toBeNull();
523
+ // Non-self delete should not mutate assistant-agnostic external bindings.
524
+ const remainingBinding = db.select()
525
+ .from(externalConversationBindings)
526
+ .where(eq(externalConversationBindings.conversationId, convId))
527
+ .get();
528
+ expect(remainingBinding).not.toBeNull();
516
529
  });
517
530
 
518
531
  test('handleDeleteConversation defaults to "self" when no assistantId provided', async () => {
@@ -530,6 +543,13 @@ describe('channel-delivery-store', () => {
530
543
  }).run();
531
544
  setConversationKey(scopedKey, convId);
532
545
  setConversationKey(legacyKey, convId);
546
+ db.insert(externalConversationBindings).values({
547
+ conversationId: convId,
548
+ sourceChannel: 'telegram',
549
+ externalChatId: 'chat-def',
550
+ createdAt: now,
551
+ updatedAt: now,
552
+ }).run();
533
553
 
534
554
  const req = new Request('http://localhost/channels/conversation', {
535
555
  method: 'DELETE',
@@ -546,5 +566,11 @@ describe('channel-delivery-store', () => {
546
566
 
547
567
  expect(getConversationByKey(scopedKey)).toBeNull();
548
568
  expect(getConversationByKey(legacyKey)).toBeNull();
569
+ // Self delete should keep external bindings in sync for the canonical route.
570
+ const remainingBinding = db.select()
571
+ .from(externalConversationBindings)
572
+ .where(eq(externalConversationBindings.conversationId, convId))
573
+ .get();
574
+ expect(remainingBinding).toBeUndefined();
549
575
  });
550
576
  });
@@ -52,6 +52,10 @@ import {
52
52
  isGuardian,
53
53
  revokeBinding as serviceRevokeBinding,
54
54
  } from '../runtime/channel-guardian-service.js';
55
+ import { handleGuardianVerification } from '../daemon/handlers/config.js';
56
+ import type { GuardianVerificationRequest, GuardianVerificationResponse } from '../daemon/ipc-contract.js';
57
+ import type { HandlerContext } from '../daemon/handlers/shared.js';
58
+ import type * as net from 'node:net';
55
59
 
56
60
  initializeDb();
57
61
 
@@ -307,28 +311,32 @@ describe('guardian service challenge validation', () => {
307
311
  resetTables();
308
312
  });
309
313
 
310
- test('createVerificationChallenge returns a secret and instruction', () => {
314
+ test('createVerificationChallenge returns a secret, verifyCommand, ttlSeconds, and instruction', () => {
311
315
  const result = createVerificationChallenge('asst-1', 'telegram');
312
316
 
313
317
  expect(result.challengeId).toBeDefined();
314
318
  expect(result.secret).toBeDefined();
315
319
  expect(result.secret.length).toBe(64); // 32 bytes hex-encoded
320
+ expect(result.verifyCommand).toBe(`/guardian_verify ${result.secret}`);
321
+ expect(result.ttlSeconds).toBe(600);
322
+ expect(result.instruction).toBeDefined();
323
+ expect(result.instruction.length).toBeGreaterThan(0);
316
324
  expect(result.instruction).toContain('/guardian_verify');
317
- expect(result.instruction).toContain(result.secret);
318
325
  });
319
326
 
320
- test('createVerificationChallenge instruction mentions Telegram for telegram channel', () => {
327
+ test('createVerificationChallenge produces a non-empty instruction for telegram channel', () => {
321
328
  const result = createVerificationChallenge('asst-1', 'telegram');
322
- expect(result.instruction).toContain('via Telegram');
323
- expect(result.instruction).not.toContain('via SMS');
329
+ expect(result.instruction).toBeDefined();
330
+ expect(result.instruction.length).toBeGreaterThan(0);
331
+ expect(result.instruction).toContain(result.verifyCommand);
324
332
  });
325
333
 
326
- test('createVerificationChallenge instruction mentions SMS for sms channel', () => {
334
+ test('createVerificationChallenge produces a non-empty instruction for sms channel', () => {
327
335
  const result = createVerificationChallenge('asst-1', 'sms');
328
- expect(result.instruction).toContain('via SMS');
329
- expect(result.instruction).not.toContain('via Telegram');
336
+ expect(result.instruction).toBeDefined();
337
+ expect(result.instruction.length).toBeGreaterThan(0);
330
338
  expect(result.instruction).toContain('/guardian_verify');
331
- expect(result.instruction).toContain(result.secret);
339
+ expect(result.instruction).toContain(result.verifyCommand);
332
340
  });
333
341
 
334
342
  test('validateAndConsumeChallenge succeeds with correct secret', () => {
@@ -373,8 +381,10 @@ describe('guardian service challenge validation', () => {
373
381
 
374
382
  expect(result.success).toBe(false);
375
383
  if (!result.success) {
376
- // Generic message to prevent oracle leakage
377
- expect(result.reason).toContain('try again later');
384
+ // Composed failure message check it is non-empty and contains "failed"
385
+ expect(result.reason).toBeDefined();
386
+ expect(result.reason.length).toBeGreaterThan(0);
387
+ expect(result.reason.toLowerCase()).toContain('failed');
378
388
  }
379
389
  });
380
390
 
@@ -400,8 +410,10 @@ describe('guardian service challenge validation', () => {
400
410
 
401
411
  expect(result.success).toBe(false);
402
412
  if (!result.success) {
403
- // Generic message to prevent oracle leakage
404
- expect(result.reason).toContain('try again later');
413
+ // Composed failure message check it is non-empty and contains "failed"
414
+ expect(result.reason).toBeDefined();
415
+ expect(result.reason.length).toBeGreaterThan(0);
416
+ expect(result.reason.toLowerCase()).toContain('failed');
405
417
  }
406
418
  });
407
419
 
@@ -939,7 +951,9 @@ describe('guardian service rate limiting', () => {
939
951
  'asst-1', 'telegram', 'another-wrong', 'user-42', 'chat-42',
940
952
  );
941
953
  expect(result.success).toBe(false);
942
- expect((result as { reason: string }).reason).toContain('try again later');
954
+ expect((result as { reason: string }).reason).toBeDefined();
955
+ expect((result as { reason: string }).reason.length).toBeGreaterThan(0);
956
+ expect((result as { reason: string }).reason.toLowerCase()).toContain('failed');
943
957
 
944
958
  // Verify the rate limit record
945
959
  const rl = getRateLimit('asst-1', 'telegram', 'user-42', 'chat-42');
@@ -971,21 +985,38 @@ describe('guardian service rate limiting', () => {
971
985
  test('rate-limit uses generic failure message (no oracle leakage)', () => {
972
986
  createVerificationChallenge('asst-1', 'telegram');
973
987
 
974
- // Trigger rate limit
975
- for (let i = 0; i < 5; i++) {
988
+ // Capture a normal invalid-code failure response
989
+ const normalFailure = validateAndConsumeChallenge(
990
+ 'asst-1', 'telegram', 'wrong-first', 'user-42', 'chat-42',
991
+ );
992
+ expect(normalFailure.success).toBe(false);
993
+ const normalReason = (normalFailure as { reason: string }).reason;
994
+
995
+ // Trigger rate limit (4 more attempts to reach 5 total)
996
+ for (let i = 0; i < 4; i++) {
976
997
  validateAndConsumeChallenge(
977
998
  'asst-1', 'telegram', `wrong-${i}`, 'user-42', 'chat-42',
978
999
  );
979
1000
  }
980
1001
 
981
- const result = validateAndConsumeChallenge(
1002
+ // Verify lockout is actually active before testing the rate-limited response
1003
+ const rl = getRateLimit('asst-1', 'telegram', 'user-42', 'chat-42');
1004
+ expect(rl).not.toBeNull();
1005
+ expect(rl!.lockedUntil).toBeGreaterThan(Date.now());
1006
+
1007
+ // The rate-limited response should be indistinguishable from normal failure
1008
+ const rateLimitedResult = validateAndConsumeChallenge(
982
1009
  'asst-1', 'telegram', 'anything', 'user-42', 'chat-42',
983
1010
  );
984
- expect(result.success).toBe(false);
985
- // Must NOT reveal "invalid", "expired", or "rate limit" specifically
986
- expect((result as { reason: string }).reason).not.toContain('Invalid');
987
- expect((result as { reason: string }).reason).not.toContain('expired');
988
- expect((result as { reason: string }).reason).not.toContain('rate limit');
1011
+ expect(rateLimitedResult.success).toBe(false);
1012
+ const rateLimitedReason = (rateLimitedResult as { reason: string }).reason;
1013
+
1014
+ // Anti-oracle: both responses must be identical
1015
+ expect(rateLimitedReason).toBe(normalReason);
1016
+
1017
+ // Neither should reveal rate-limiting info
1018
+ expect(rateLimitedReason).not.toContain('rate limit');
1019
+ expect(normalReason).not.toContain('rate limit');
989
1020
  });
990
1021
 
991
1022
  test('rate limit does not affect different actors', () => {
@@ -1186,3 +1217,211 @@ describe('assistant-scoped approval request lookups', () => {
1186
1217
  expect(foundB!.toolName).toBe('browser');
1187
1218
  });
1188
1219
  });
1220
+
1221
+ // ═══════════════════════════════════════════════════════════════════════════
1222
+ // 10. IPC handler — channel-aware guardian status response
1223
+ // ═══════════════════════════════════════════════════════════════════════════
1224
+
1225
+ /**
1226
+ * Creates a minimal mock HandlerContext that captures the response sent via ctx.send().
1227
+ */
1228
+ function createMockCtx(): { ctx: HandlerContext; lastResponse: () => GuardianVerificationResponse | null } {
1229
+ let captured: GuardianVerificationResponse | null = null;
1230
+ const ctx = {
1231
+ sessions: new Map(),
1232
+ socketToSession: new Map(),
1233
+ cuSessions: new Map(),
1234
+ socketToCuSession: new Map(),
1235
+ cuObservationParseSequence: new Map(),
1236
+ socketSandboxOverride: new Map(),
1237
+ sharedRequestTimestamps: [],
1238
+ debounceTimers: { schedule: () => {}, cancel: () => {} } as unknown as HandlerContext['debounceTimers'],
1239
+ suppressConfigReload: false,
1240
+ setSuppressConfigReload: () => {},
1241
+ updateConfigFingerprint: () => {},
1242
+ send: (_socket: net.Socket, msg: unknown) => { captured = msg as GuardianVerificationResponse; },
1243
+ broadcast: () => {},
1244
+ clearAllSessions: () => 0,
1245
+ getOrCreateSession: () => Promise.resolve({} as never),
1246
+ touchSession: () => {},
1247
+ } as unknown as HandlerContext;
1248
+ return { ctx, lastResponse: () => captured };
1249
+ }
1250
+
1251
+ const mockSocket = {} as net.Socket;
1252
+
1253
+ describe('IPC handler channel-aware guardian status', () => {
1254
+ beforeEach(() => {
1255
+ resetTables();
1256
+ });
1257
+
1258
+ test('status action for telegram returns channel and assistantId fields', () => {
1259
+ const { ctx, lastResponse } = createMockCtx();
1260
+ const msg: GuardianVerificationRequest = {
1261
+ type: 'guardian_verification',
1262
+ action: 'status',
1263
+ channel: 'telegram',
1264
+ assistantId: 'self',
1265
+ };
1266
+
1267
+ handleGuardianVerification(msg, mockSocket, ctx);
1268
+
1269
+ const resp = lastResponse();
1270
+ expect(resp).not.toBeNull();
1271
+ expect(resp!.success).toBe(true);
1272
+ expect(resp!.channel).toBe('telegram');
1273
+ expect(resp!.assistantId).toBe('self');
1274
+ expect(resp!.bound).toBe(false);
1275
+ expect(resp!.guardianDeliveryChatId).toBeUndefined();
1276
+ });
1277
+
1278
+ test('status action for sms returns channel: sms and assistantId: self', () => {
1279
+ const { ctx, lastResponse } = createMockCtx();
1280
+ const msg: GuardianVerificationRequest = {
1281
+ type: 'guardian_verification',
1282
+ action: 'status',
1283
+ channel: 'sms',
1284
+ assistantId: 'self',
1285
+ };
1286
+
1287
+ handleGuardianVerification(msg, mockSocket, ctx);
1288
+
1289
+ const resp = lastResponse();
1290
+ expect(resp).not.toBeNull();
1291
+ expect(resp!.success).toBe(true);
1292
+ expect(resp!.channel).toBe('sms');
1293
+ expect(resp!.assistantId).toBe('self');
1294
+ expect(resp!.bound).toBe(false);
1295
+ });
1296
+
1297
+ test('status action returns guardianDeliveryChatId when bound', () => {
1298
+ createBinding({
1299
+ assistantId: 'self',
1300
+ channel: 'telegram',
1301
+ guardianExternalUserId: 'user-42',
1302
+ guardianDeliveryChatId: 'chat-42',
1303
+ });
1304
+
1305
+ const { ctx, lastResponse } = createMockCtx();
1306
+ const msg: GuardianVerificationRequest = {
1307
+ type: 'guardian_verification',
1308
+ action: 'status',
1309
+ channel: 'telegram',
1310
+ assistantId: 'self',
1311
+ };
1312
+
1313
+ handleGuardianVerification(msg, mockSocket, ctx);
1314
+
1315
+ const resp = lastResponse();
1316
+ expect(resp).not.toBeNull();
1317
+ expect(resp!.success).toBe(true);
1318
+ expect(resp!.bound).toBe(true);
1319
+ expect(resp!.guardianExternalUserId).toBe('user-42');
1320
+ expect(resp!.guardianDeliveryChatId).toBe('chat-42');
1321
+ expect(resp!.channel).toBe('telegram');
1322
+ expect(resp!.assistantId).toBe('self');
1323
+ });
1324
+
1325
+ test('status action returns guardian username/displayName from binding metadata', () => {
1326
+ createBinding({
1327
+ assistantId: 'self',
1328
+ channel: 'telegram',
1329
+ guardianExternalUserId: 'user-43',
1330
+ guardianDeliveryChatId: 'chat-43',
1331
+ metadataJson: JSON.stringify({ username: 'guardian_handle', displayName: 'Guardian Name' }),
1332
+ });
1333
+
1334
+ const { ctx, lastResponse } = createMockCtx();
1335
+ const msg: GuardianVerificationRequest = {
1336
+ type: 'guardian_verification',
1337
+ action: 'status',
1338
+ channel: 'telegram',
1339
+ assistantId: 'self',
1340
+ };
1341
+
1342
+ handleGuardianVerification(msg, mockSocket, ctx);
1343
+
1344
+ const resp = lastResponse();
1345
+ expect(resp).not.toBeNull();
1346
+ expect(resp!.guardianUsername).toBe('guardian_handle');
1347
+ expect(resp!.guardianDisplayName).toBe('Guardian Name');
1348
+ });
1349
+
1350
+ test('status action defaults channel to telegram when omitted (backward compat)', () => {
1351
+ const { ctx, lastResponse } = createMockCtx();
1352
+ const msg: GuardianVerificationRequest = {
1353
+ type: 'guardian_verification',
1354
+ action: 'status',
1355
+ // channel omitted — should default to 'telegram'
1356
+ };
1357
+
1358
+ handleGuardianVerification(msg, mockSocket, ctx);
1359
+
1360
+ const resp = lastResponse();
1361
+ expect(resp).not.toBeNull();
1362
+ expect(resp!.channel).toBe('telegram');
1363
+ expect(resp!.assistantId).toBe('self');
1364
+ });
1365
+
1366
+ test('status action defaults assistantId to self when omitted (backward compat)', () => {
1367
+ const { ctx, lastResponse } = createMockCtx();
1368
+ const msg: GuardianVerificationRequest = {
1369
+ type: 'guardian_verification',
1370
+ action: 'status',
1371
+ channel: 'sms',
1372
+ // assistantId omitted — should default to 'self'
1373
+ };
1374
+
1375
+ handleGuardianVerification(msg, mockSocket, ctx);
1376
+
1377
+ const resp = lastResponse();
1378
+ expect(resp).not.toBeNull();
1379
+ expect(resp!.assistantId).toBe('self');
1380
+ expect(resp!.channel).toBe('sms');
1381
+ });
1382
+
1383
+ test('status action with custom assistantId returns correct value', () => {
1384
+ createBinding({
1385
+ assistantId: 'asst-custom',
1386
+ channel: 'telegram',
1387
+ guardianExternalUserId: 'user-77',
1388
+ guardianDeliveryChatId: 'chat-77',
1389
+ });
1390
+
1391
+ const { ctx, lastResponse } = createMockCtx();
1392
+ const msg: GuardianVerificationRequest = {
1393
+ type: 'guardian_verification',
1394
+ action: 'status',
1395
+ channel: 'telegram',
1396
+ assistantId: 'asst-custom',
1397
+ };
1398
+
1399
+ handleGuardianVerification(msg, mockSocket, ctx);
1400
+
1401
+ const resp = lastResponse();
1402
+ expect(resp).not.toBeNull();
1403
+ expect(resp!.success).toBe(true);
1404
+ expect(resp!.bound).toBe(true);
1405
+ expect(resp!.assistantId).toBe('asst-custom');
1406
+ expect(resp!.channel).toBe('telegram');
1407
+ expect(resp!.guardianExternalUserId).toBe('user-77');
1408
+ expect(resp!.guardianDeliveryChatId).toBe('chat-77');
1409
+ });
1410
+
1411
+ test('status action for unbound sms does not return guardianDeliveryChatId', () => {
1412
+ const { ctx, lastResponse } = createMockCtx();
1413
+ const msg: GuardianVerificationRequest = {
1414
+ type: 'guardian_verification',
1415
+ action: 'status',
1416
+ channel: 'sms',
1417
+ };
1418
+
1419
+ handleGuardianVerification(msg, mockSocket, ctx);
1420
+
1421
+ const resp = lastResponse();
1422
+ expect(resp).not.toBeNull();
1423
+ expect(resp!.bound).toBe(false);
1424
+ expect(resp!.guardianDeliveryChatId).toBeUndefined();
1425
+ expect(resp!.guardianExternalUserId).toBeUndefined();
1426
+ });
1427
+ });